skillhub/cli/test/integration/upgrade-command.test.ts
XiaoSeS 2b831f31a9
fix(cli): enforce fingerprint-safe install and sync (#804)
* 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>
2026-09-02 18:58:17 +08:00

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)
})
})