mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +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>
727 lines
31 KiB
TypeScript
727 lines
31 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
|
import { isAbsolute, join } from 'node:path'
|
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import { createTempHome } from '../helpers/temp-env'
|
|
import { startFakeRegistry } from '../helpers/fake-registry'
|
|
import { runCli } from '../helpers/run-cli'
|
|
import { executeSkillUpgradePlan, planSkillUpgrades } from '../../src/services/upgrade-service'
|
|
import { installSkill } from '../../src/services/install-service'
|
|
import { renderUpgradeResult } from '../../src/commands/upgrade'
|
|
|
|
let registries: Array<Awaited<ReturnType<typeof startFakeRegistry>>> = []
|
|
|
|
afterEach(() => {
|
|
for (const registry of registries) registry.stop()
|
|
registries = []
|
|
})
|
|
|
|
function makeSkill(content: string): { fingerprint: string; zipBytes: Uint8Array } {
|
|
const bytes = strToU8(content)
|
|
const fileHash = createHash('sha256').update(bytes).digest('hex')
|
|
return {
|
|
fingerprint: `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`,
|
|
zipBytes: zipSync({ 'SKILL.md': bytes })
|
|
}
|
|
}
|
|
|
|
function makeSkillZip(content: string): Uint8Array {
|
|
return makeSkill(content).zipBytes
|
|
}
|
|
|
|
async function exists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
describe('upgrade command', () => {
|
|
test('check is side-effect free and execute upgrades an installed skill', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'skillhub-registry',
|
|
version: '1.0.0',
|
|
versionId: 1,
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
|
|
const installed = await runCli([
|
|
'install', '@global/skillhub-registry', '--dir', rootDir, '--registry', registry.url
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(installed.exitCode).toBe(0)
|
|
|
|
skill.version = '1.1.0'
|
|
skill.versionId = 2
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
|
|
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
|
|
const metadataPath = join(rootDir, 'skillhub-registry', '.skillhub', 'metadata.json')
|
|
const inventoryBeforeCheck = await readFile(inventoryPath, 'utf-8')
|
|
const metadataBeforeCheck = await readFile(metadataPath, 'utf-8')
|
|
const checked = await runCli([
|
|
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(checked.exitCode).toBe(0)
|
|
expect(JSON.parse(checked.stdout).items[0]).toMatchObject({
|
|
coordinate: '@global/skillhub-registry',
|
|
currentVersion: '1.0.0',
|
|
remoteVersion: '1.1.0',
|
|
action: 'upgrade'
|
|
})
|
|
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v1')
|
|
expect(registry.received.downloads).toBe(1)
|
|
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck)
|
|
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck)
|
|
|
|
const checkedAgain = await runCli([
|
|
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(checkedAgain.exitCode).toBe(0)
|
|
expect(checkedAgain.stdout).toBe(checked.stdout)
|
|
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck)
|
|
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck)
|
|
expect(registry.received.downloads).toBe(1)
|
|
|
|
const upgraded = await runCli([
|
|
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(upgraded.exitCode).toBe(0)
|
|
expect(JSON.parse(upgraded.stdout).items[0].action).toBe('upgraded')
|
|
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
expect(registry.received.downloads).toBe(2)
|
|
|
|
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
|
|
expect(metadata).toMatchObject({
|
|
schemaVersion: 1,
|
|
version: '1.1.0',
|
|
versionId: 2,
|
|
fingerprint: makeSkill('# v2').fingerprint
|
|
})
|
|
expect(Object.keys(metadata.files)).toContain('SKILL.md')
|
|
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
|
|
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
|
|
})
|
|
|
|
test('local changes block by default and --force replaces only the same source', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'team',
|
|
slug: 'code-review',
|
|
version: '1.0.0',
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@team/code-review', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
await writeFile(join(rootDir, 'code-review', 'SKILL.md'), '# locally edited')
|
|
skill.version = '1.1.0'
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
|
|
const blocked = await runCli([
|
|
'upgrade', '@team/code-review', '--registry', registry.url, '--agent', 'custom', '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(blocked.exitCode).toBe(6)
|
|
expect(JSON.parse(blocked.stdout).items[0]).toMatchObject({ action: 'blocked' })
|
|
expect(JSON.parse(blocked.stdout).items[0].reason).toContain('local changes')
|
|
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# locally edited')
|
|
|
|
const forced = await runCli([
|
|
'upgrade', '@team/code-review', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(forced.exitCode).toBe(0)
|
|
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
})
|
|
|
|
test('a local edit made after planning is rechecked before replacement', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'late-edit',
|
|
version: '1.0.0',
|
|
versionId: 1,
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/late-edit', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
}, { cwd: env.cwd })
|
|
|
|
skill.version = '1.1.0'
|
|
skill.versionId = 2
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
const tokenForRegistry = async () => undefined
|
|
const plan = await planSkillUpgrades({
|
|
coordinates: ['@global/late-edit'],
|
|
registry: registry.url,
|
|
force: false,
|
|
home: env.home,
|
|
tokenForRegistry
|
|
})
|
|
await writeFile(join(rootDir, 'late-edit', 'SKILL.md'), '# edited after planning')
|
|
|
|
const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry })
|
|
expect(result.items[0]).toMatchObject({ action: 'failed' })
|
|
expect(result.items[0]?.reason).toContain('local changes detected after upgrade planning')
|
|
expect(await readFile(join(rootDir, 'late-edit', 'SKILL.md'), 'utf-8'))
|
|
.toBe('# edited after planning')
|
|
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
|
|
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint })
|
|
})
|
|
|
|
test('a target removed after planning is not recreated by upgrade', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'removed-late',
|
|
version: '1.0.0',
|
|
versionId: 1,
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/removed-late', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
}, { cwd: env.cwd })
|
|
|
|
skill.version = '1.1.0'
|
|
skill.versionId = 2
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
const tokenForRegistry = async () => undefined
|
|
const plan = await planSkillUpgrades({
|
|
coordinates: ['@global/removed-late'],
|
|
registry: registry.url,
|
|
force: false,
|
|
home: env.home,
|
|
tokenForRegistry
|
|
})
|
|
const skillDir = join(rootDir, 'removed-late')
|
|
await rm(skillDir, { recursive: true })
|
|
|
|
const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry })
|
|
expect(result.items[0]).toMatchObject({ action: 'failed' })
|
|
expect(result.items[0]?.reason).toContain('installed target disappeared before upgrade commit')
|
|
expect(await exists(skillDir)).toBe(false)
|
|
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
|
|
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint })
|
|
})
|
|
|
|
test('new installs persist absolute targets and legacy relative targets are blocked safely', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'portable',
|
|
version: '1.0.0',
|
|
versionId: 1,
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const installed = await runCli([
|
|
'install', '@global/portable', '--dir', 'skills', '--registry', registry.url
|
|
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
|
expect(installed.exitCode).toBe(0)
|
|
|
|
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
|
|
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
|
|
expect(isAbsolute(inventory.items[0].targets[0].rootDir)).toBe(true)
|
|
expect(inventory.items[0].targets[0].installDir)
|
|
.toBe(join(inventory.items[0].targets[0].rootDir, 'portable'))
|
|
expect(await realpath(inventory.items[0].targets[0].rootDir))
|
|
.toBe(await realpath(join(env.cwd, 'skills')))
|
|
expect(await realpath(inventory.items[0].targets[0].installDir))
|
|
.toBe(await realpath(join(env.cwd, 'skills', 'portable')))
|
|
|
|
inventory.items[0].targets[0].rootDir = 'skills'
|
|
inventory.items[0].targets[0].installDir = join('skills', 'portable')
|
|
await writeFile(inventoryPath, JSON.stringify(inventory))
|
|
skill.version = '1.1.0'
|
|
skill.versionId = 2
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
|
|
const otherCwd = join(env.cwd, 'other')
|
|
await mkdir(otherCwd, { recursive: true })
|
|
const result = await runCli([
|
|
'upgrade', '@global/portable', '--registry', registry.url, '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home }, { cwd: otherCwd })
|
|
expect(result.exitCode).toBe(6)
|
|
expect(JSON.parse(result.stdout).items[0].reason).toContain('legacy relative target path')
|
|
expect(await readFile(join(env.cwd, 'skills', 'portable', 'SKILL.md'), 'utf-8')).toBe('# v1')
|
|
})
|
|
|
|
test('source conflict is a hard block even with --force', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'demo',
|
|
version: '1.0.0',
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/demo', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
const metadataPath = join(rootDir, 'demo', '.skillhub', 'metadata.json')
|
|
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
|
|
metadata.namespace = 'another-team'
|
|
await writeFile(metadataPath, JSON.stringify(metadata))
|
|
skill.version = '2.0.0'
|
|
skill.fingerprint = makeSkill('# v2').fingerprint
|
|
|
|
const result = await runCli([
|
|
'upgrade', '@global/demo', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(result.exitCode).toBe(6)
|
|
expect(JSON.parse(result.stdout).items[0].reason).toContain('source-conflict')
|
|
expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# v1')
|
|
})
|
|
|
|
test('a bare slug must identify exactly one installed source', async () => {
|
|
const env = await createTempHome()
|
|
const skillA = { namespace: 'team-a', slug: 'demo', version: '1.0.0', ...makeSkill('# A') }
|
|
const skillB = { namespace: 'team-b', slug: 'demo', version: '1.0.0', ...makeSkill('# B') }
|
|
const registryA = await startFakeRegistry({ skills: [skillA] })
|
|
const registryB = await startFakeRegistry({ skills: [skillB] })
|
|
registries.push(registryA, registryB)
|
|
const rootA = join(env.cwd, 'a')
|
|
const rootB = join(env.cwd, 'b')
|
|
await mkdir(rootA, { recursive: true })
|
|
await mkdir(rootB, { recursive: true })
|
|
await runCli(['install', '@team-a/demo', '--dir', rootA, '--registry', registryA.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
await runCli(['install', '@team-b/demo', '--dir', rootB, '--registry', registryB.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
const ambiguous = await runCli(['upgrade', 'demo', '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(ambiguous.exitCode).toBe(5)
|
|
expect(ambiguous.stderr).toContain('ambiguous')
|
|
|
|
const selected = await runCli(['upgrade', 'demo', '--namespace', 'team-a', '--registry', registryA.url, '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(selected.exitCode).toBe(0)
|
|
expect(selected.stdout).toContain('@team-a/demo')
|
|
|
|
const fullCoordinate = await runCli(['upgrade', '@team-a/demo', '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(fullCoordinate.exitCode).toBe(0)
|
|
expect(fullCoordinate.stdout).toContain('@team-a/demo')
|
|
expect(fullCoordinate.stdout).not.toContain('@team-b/demo')
|
|
|
|
const noNamespaceMatch = await runCli(['upgrade', 'demo', '--namespace', 'missing', '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(noNamespaceMatch.exitCode).toBe(5)
|
|
expect(noNamespaceMatch.stderr).toContain('not installed')
|
|
})
|
|
|
|
test('target filters select deterministically and missing matches never install', async () => {
|
|
const env = await createTempHome()
|
|
const skill = { namespace: 'global', slug: 'filtered', version: '1.0.0', ...makeSkill('# v1') }
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/filtered', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
for (const args of [
|
|
['--dir', rootDir],
|
|
['--agent', 'custom'],
|
|
['--namespace', 'global'],
|
|
['--registry', registry.url]
|
|
]) {
|
|
const result = await runCli(['upgrade', 'filtered', ...args, '--force', '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(result.exitCode).toBe(0)
|
|
}
|
|
|
|
for (const args of [
|
|
['--dir', join(env.cwd, 'missing')],
|
|
['--agent', 'codex'],
|
|
['--namespace', 'missing'],
|
|
['--registry', 'http://unmatched.invalid']
|
|
]) {
|
|
const result = await runCli(['upgrade', 'filtered', ...args, '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(result.exitCode).toBe(5)
|
|
expect(result.stderr).toContain('not installed')
|
|
}
|
|
expect(registry.received.downloads).toBe(1)
|
|
})
|
|
|
|
test('one resolved archive is reused for every managed target', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'shared',
|
|
version: '1.0.0',
|
|
...makeSkill('# v1')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
|
|
const installed = await runCli([
|
|
'install', '@global/shared', '--agent', 'codex', '--agent', 'claude-code', '--registry', registry.url
|
|
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
|
expect(installed.exitCode).toBe(0)
|
|
expect(registry.received.resolves).toBe(1)
|
|
expect(registry.received.downloads).toBe(1)
|
|
|
|
skill.version = '1.1.0'
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
|
|
const partial = await runCli([
|
|
'upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
|
expect(partial.exitCode).toBe(6)
|
|
expect(JSON.parse(partial.stdout).items[0].reason).toContain('partial-target')
|
|
expect(registry.received.downloads).toBe(1)
|
|
|
|
const upgraded = await runCli(['upgrade', '@global/shared', '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
}, { cwd: env.cwd })
|
|
expect(upgraded.exitCode).toBe(0)
|
|
expect(registry.received.resolves).toBe(2)
|
|
expect(registry.received.downloads).toBe(2)
|
|
expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
|
|
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
|
|
expect(inventory.items[0].targets).toHaveLength(2)
|
|
|
|
const listed = await runCli(['list', '--json', '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(listed.exitCode).toBe(0)
|
|
expect(JSON.parse(listed.stdout).items[0]).toMatchObject({ version: '1.1.0' })
|
|
})
|
|
|
|
test('never downgrades when the registry latest version moves backwards', async () => {
|
|
const env = await createTempHome()
|
|
const skill = {
|
|
namespace: 'global',
|
|
slug: 'stable',
|
|
version: '2.0.0',
|
|
versionId: 2,
|
|
...makeSkill('# v2')
|
|
}
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/stable', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
skill.version = '1.0.0'
|
|
skill.versionId = 1
|
|
Object.assign(skill, makeSkill('# v1'))
|
|
|
|
const result = await runCli([
|
|
'upgrade', '@global/stable', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(result.exitCode).toBe(6)
|
|
expect(JSON.parse(result.stdout).items[0].reason).toContain('older')
|
|
expect(await readFile(join(rootDir, 'stable', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
expect(registry.received.downloads).toBe(1)
|
|
})
|
|
|
|
test('keeps local files when resolve is unavailable or same-version content drifts', async () => {
|
|
const env = await createTempHome()
|
|
const failures: { resolve?: 'server_error' } = {}
|
|
const skill = { namespace: 'global', slug: 'resilient', version: '1.0.0', ...makeSkill('# v1') }
|
|
const registry = await startFakeRegistry({ skills: [skill], failures })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/resilient', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
failures.resolve = 'server_error'
|
|
const unavailable = await runCli([
|
|
'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(unavailable.exitCode).toBe(6)
|
|
expect(JSON.parse(unavailable.stdout).items[0].action).toBe('blocked')
|
|
expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1')
|
|
|
|
delete failures.resolve
|
|
Object.assign(skill, makeSkill('# changed without version bump'))
|
|
const drifted = await runCli([
|
|
'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(drifted.exitCode).toBe(6)
|
|
expect(JSON.parse(drifted.stdout).items[0].reason).toContain('without a newer version')
|
|
expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1')
|
|
expect(registry.received.downloads).toBe(1)
|
|
})
|
|
|
|
test('a blocked batch reports a plan and does not claim successful writes', async () => {
|
|
const env = await createTempHome()
|
|
const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') }
|
|
const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') }
|
|
const registry = await startFakeRegistry({ skills: [first, second] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
for (const slug of ['first', 'second']) {
|
|
await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
}
|
|
|
|
first.version = '1.1.0'
|
|
Object.assign(first, makeSkill('# first v2'))
|
|
second.version = '1.1.0'
|
|
Object.assign(second, makeSkill('# second v2'))
|
|
await writeFile(join(rootDir, 'second', 'SKILL.md'), '# local change')
|
|
|
|
const result = await runCli([
|
|
'upgrade', '@global/first', '@global/second', '--registry', registry.url, '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(result.exitCode).toBe(6)
|
|
const output = JSON.parse(result.stdout)
|
|
expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/first')).action).toBe('upgrade')
|
|
expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/second')).action).toBe('blocked')
|
|
expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v1')
|
|
})
|
|
|
|
test('a runtime batch failure reports committed, failed, and unattempted skills', async () => {
|
|
const env = await createTempHome()
|
|
const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') }
|
|
const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') }
|
|
const third = { namespace: 'global', slug: 'third', version: '1.0.0', ...makeSkill('# third v1') }
|
|
const registry = await startFakeRegistry({ skills: [first, second, third] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
for (const slug of ['first', 'second', 'third']) {
|
|
await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
}
|
|
|
|
first.version = '1.1.0'
|
|
Object.assign(first, makeSkill('# first v2'))
|
|
second.version = '1.1.0'
|
|
second.fingerprint = makeSkill('# second v2').fingerprint
|
|
second.zipBytes = strToU8('not a zip archive')
|
|
third.version = '1.1.0'
|
|
Object.assign(third, makeSkill('# third v2'))
|
|
|
|
const result = await runCli([
|
|
'upgrade', '@global/first', '@global/second', '@global/third',
|
|
'--registry', registry.url, '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(result.exitCode).toBe(1)
|
|
const output = JSON.parse(result.stdout)
|
|
expect(output.summary).toEqual({ upgraded: 1, unchanged: 0, failed: 1, notAttempted: 1 })
|
|
expect(output.items.map((item: { action: string }) => item.action))
|
|
.toEqual(['upgraded', 'failed', 'not-attempted'])
|
|
expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v2')
|
|
expect(await readFile(join(rootDir, 'second', 'SKILL.md'), 'utf-8')).toBe('# second v1')
|
|
expect(await readFile(join(rootDir, 'third', 'SKILL.md'), 'utf-8')).toBe('# third v1')
|
|
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
|
|
expect(inventory.items.find((item: { slug: string }) => item.slug === 'first').version).toBe('1.1.0')
|
|
expect(inventory.items.find((item: { slug: string }) => item.slug === 'second').version).toBe('1.0.0')
|
|
expect(inventory.items.find((item: { slug: string }) => item.slug === 'third').version).toBe('1.0.0')
|
|
expect(registry.received.downloads).toBe(5)
|
|
})
|
|
|
|
test('a committed upgrade keeps success and renders a post-commit warning', async () => {
|
|
const env = await createTempHome()
|
|
const skill = { namespace: 'global', slug: 'warned', version: '1.0.0', ...makeSkill('# v1') }
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/warned', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
skill.version = '1.1.0'
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
const tokenForRegistry = async () => undefined
|
|
const plan = await planSkillUpgrades({
|
|
coordinates: ['@global/warned'],
|
|
registry: registry.url,
|
|
force: false,
|
|
home: env.home,
|
|
tokenForRegistry
|
|
})
|
|
|
|
const result = await executeSkillUpgradePlan(plan, {
|
|
home: env.home,
|
|
tokenForRegistry,
|
|
installSkillFn: options => installSkill({
|
|
...options,
|
|
acquireTargetLock: async () => async () => { throw new Error('simulated release failure') }
|
|
})
|
|
})
|
|
|
|
expect(result).toMatchObject({ upgraded: 1, failed: 0 })
|
|
expect(result.items[0]).toMatchObject({ action: 'upgraded' })
|
|
expect(result.items[0]?.warnings).toEqual(['target lock cleanup failed: simulated release failure'])
|
|
expect(JSON.parse(renderUpgradeResult(plan, result, true)).items[0].warnings).toHaveLength(1)
|
|
expect(renderUpgradeResult(plan, result, false)).toContain('upgraded')
|
|
expect(renderUpgradeResult(plan, result, false)).toContain('[warning: target lock cleanup failed')
|
|
expect(await readFile(join(rootDir, 'warned', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
|
|
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
|
|
})
|
|
|
|
test('legacy metadata without a file baseline requires explicit force migration', async () => {
|
|
const env = await createTempHome()
|
|
const skill = { namespace: 'global', slug: 'legacy', version: '1.0.0', ...makeSkill('# v1') }
|
|
const registry = await startFakeRegistry({ skills: [skill] })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
await runCli(['install', '@global/legacy', '--dir', rootDir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
const metadataPath = join(rootDir, 'legacy', '.skillhub', 'metadata.json')
|
|
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
|
|
delete metadata.files
|
|
delete metadata.schemaVersion
|
|
await writeFile(metadataPath, JSON.stringify(metadata))
|
|
skill.version = '1.1.0'
|
|
Object.assign(skill, makeSkill('# v2'))
|
|
|
|
const blocked = await runCli([
|
|
'upgrade', '@global/legacy', '--registry', registry.url, '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(blocked.exitCode).toBe(6)
|
|
expect(JSON.parse(blocked.stdout).items[0].reason).toContain('no file baseline')
|
|
|
|
const migrated = await runCli([
|
|
'upgrade', '@global/legacy', '--registry', registry.url, '--force', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(migrated.exitCode).toBe(0)
|
|
expect(await readFile(join(rootDir, 'legacy', 'SKILL.md'), 'utf-8')).toBe('# v2')
|
|
const migratedMetadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
|
|
expect(migratedMetadata.schemaVersion).toBe(1)
|
|
expect(Object.keys(migratedMetadata.files)).toContain('SKILL.md')
|
|
})
|
|
|
|
test('never installs a missing skill and never offers an implicit upgrade-all', async () => {
|
|
const env = await createTempHome()
|
|
const missing = await runCli(['upgrade', '@global/missing', '--check'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
expect(missing.exitCode).toBe(5)
|
|
expect(missing.stderr).toContain('use skillhub install')
|
|
|
|
const empty = await runCli(['upgrade'], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(empty.exitCode).toBe(5)
|
|
expect(empty.stderr).toContain('at least one')
|
|
|
|
const tooMany = await runCli([
|
|
'upgrade', ...Array.from({ length: 51 }, (_, index) => `@global/skill-${index}`)
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(tooMany.exitCode).toBe(5)
|
|
expect(tooMany.stderr).toContain('at most 50')
|
|
})
|
|
|
|
test('accepts exactly fifty explicitly installed coordinates', async () => {
|
|
const env = await createTempHome()
|
|
const skills = Array.from({ length: 50 }, (_, index) => ({
|
|
namespace: 'global',
|
|
slug: `skill-${index}`,
|
|
version: '1.0.0',
|
|
fingerprint: `fp-${index}`,
|
|
zipBytes: makeSkillZip(`# skill ${index}`)
|
|
}))
|
|
const registry = await startFakeRegistry({ skills })
|
|
registries.push(registry)
|
|
const rootDir = join(env.cwd, 'skills')
|
|
await mkdir(rootDir, { recursive: true })
|
|
const items = []
|
|
for (const skill of skills) {
|
|
const skillDir = join(rootDir, skill.slug)
|
|
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
|
await writeFile(join(skillDir, 'SKILL.md'), `# ${skill.slug}`)
|
|
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({
|
|
registry: registry.url,
|
|
namespace: skill.namespace,
|
|
slug: skill.slug,
|
|
version: skill.version,
|
|
fingerprint: skill.fingerprint,
|
|
source: 'skillhub'
|
|
}))
|
|
items.push({
|
|
registry: registry.url,
|
|
namespace: skill.namespace,
|
|
slug: skill.slug,
|
|
version: skill.version,
|
|
fingerprint: skill.fingerprint,
|
|
targets: [{
|
|
agent: 'custom', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z'
|
|
}]
|
|
})
|
|
}
|
|
await mkdir(join(env.home, '.skillhub'), { recursive: true })
|
|
await writeFile(join(env.home, '.skillhub', 'inventory.json'), JSON.stringify({ items }))
|
|
|
|
const result = await runCli([
|
|
'upgrade', ...skills.map(skill => `@global/${skill.slug}`),
|
|
'--registry', registry.url, '--force', '--check', '--json'
|
|
], { HOME: env.home, USERPROFILE: env.home })
|
|
expect(result.exitCode).toBe(0)
|
|
expect(JSON.parse(result.stdout).summary).toMatchObject({ unchanged: 50, blocked: 0 })
|
|
expect(registry.received.downloads).toBe(0)
|
|
})
|
|
})
|