skillhub/cli/test/unit/services/install-service.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

655 lines
27 KiB
TypeScript

import { createHash } from 'node:crypto'
import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync } from 'fflate'
import { installSkill } from '../../../src/services/install-service'
import { planSkillUpgrades } from '../../../src/services/upgrade-service'
import { removeLocalSkill } from '../../../src/services/remove-service'
import { skillTargetLockPath } from '../../../src/services/skill-target-lock'
import { EXIT } from '../../../src/shared/constants'
const originalFetch = globalThis.fetch
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
function skillFingerprint(zipEntries: Record<string, string>): string {
const aggregate = createHash('sha256')
for (const [name, content] of Object.entries(zipEntries).sort(([left], [right]) => left.localeCompare(right))) {
const fileHash = createHash('sha256').update(content).digest('hex')
aggregate.update(`${name}:${fileHash}\n`, 'utf8')
}
return `sha256:${aggregate.digest('hex')}`
}
function installFetch(zipEntries: Record<string, string>, fingerprint = skillFingerprint(zipEntries)): typeof fetch {
const archive = zipSync(Object.fromEntries(
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
), fingerprint)
}
function installFetchWithDownloadResponse(downloadResponse: Response, fingerprint = 'fp'): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({
code: 0,
data: {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
versionId: 1,
fingerprint,
downloadUrl: '/download'
}
})
}
if (path.endsWith('/download')) {
return downloadResponse.clone()
}
return Response.json({ code: 404 }, { status: 404 })
}
return fakeFetch as unknown as typeof fetch
}
async function writeManagedMetadata(
skillDir: string,
identity: { registry: string; namespace: string; slug: string } = {
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo'
}
): Promise<void> {
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({
...identity,
version: '0.1.0',
source: 'skillhub'
}))
}
describe('installSkill', () => {
afterEach(() => {
globalThis.fetch = originalFetch
})
test('fails when target skill directory already exists without metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'local.txt'), 'keep')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('skill already installed')
})
test('preflights all targets before writing when a later target is occupied', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-'))
const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-'))
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
await mkdir(secondSkillDir, { recursive: true })
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' }
],
force: false,
home
})).rejects.toThrow(`skill already installed at ${secondSkillDir}`)
expect(await exists(firstSkillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rejects a downloaded fingerprint mismatch before replacing files or inventory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Unexpected' }, 'sha256:unexpected')
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Existing')
await writeManagedMetadata(skillDir)
await mkdir(join(home, '.skillhub'), { recursive: true })
const inventoryBefore = JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-09-01T00:00:00Z'
}]
}]
})
await writeFile(inventoryPath, inventoryBefore)
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toMatchObject({
exitCode: EXIT.validation,
message: 'downloaded skill fingerprint does not match the resolved release'
})
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Existing')
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBefore)
expect((await readdir(rootDir)).some(name => name.includes('.install-'))).toBe(false)
})
test('rejects canonical target aliases before writing any installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const genericRoot = join(targetParent, 'generic')
const codexRoot = join(targetParent, 'codex')
const skillDir = join(genericRoot, 'demo')
try {
await mkdir(genericRoot, { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' },
{ agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' }
],
force: false,
home
})).rejects.toThrow('multiple install targets resolve to')
expect(await exists(skillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
} finally {
await rm(home, { recursive: true, force: true })
await rm(targetParent, { recursive: true, force: true })
}
})
test('migrates a canonical inventory target to its selected path alias without duplication', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Reinstalled' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-alias-'))
const realRoot = join(parent, 'real')
const aliasRoot = join(parent, 'alias')
const realSkillDir = join(realRoot, 'demo')
const aliasSkillDir = join(aliasRoot, 'demo')
await mkdir(realSkillDir, { recursive: true })
await writeFile(join(realSkillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(realSkillDir)
await symlink(realRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir: realRoot,
installDir: realSkillDir,
installedAt: '2026-09-01T00:00:00Z'
}]
}]
}))
await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir: aliasRoot, scope: 'project', source: 'explicit' }],
force: true,
home
})
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0].targets).toEqual([
expect.objectContaining({ rootDir: aliasRoot, installDir: aliasSkillDir })
])
const plan = await planSkillUpgrades({
coordinates: ['@global/demo'],
registry: 'http://registry.test',
force: false,
home,
tokenForRegistry: async () => undefined
})
expect(plan).toMatchObject({ blocked: 0, unchanged: 1 })
await removeLocalSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
home
})
expect(await exists(realSkillDir)).toBe(false)
expect((JSON.parse(await readFile(inventoryPath, 'utf-8'))).items).toEqual([])
})
test('force replaces the old skill directory instead of overlaying files', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'stale.txt'), 'old')
await writeManagedMetadata(skillDir)
await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# New')
expect(await exists(join(skillDir, 'stale.txt'))).toBe(false)
})
test('reports lock cleanup failure as a warning after committing the installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Committed' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
const result = await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
acquireTargetLock: async () => async () => { throw new Error('simulated release failure') }
})
expect(result.warnings).toEqual(['target lock cleanup failed: simulated release failure'])
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Committed')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({
version: '1.0.0',
fingerprint: skillFingerprint({ 'SKILL.md': '# Committed' })
})
})
test('force rejects a different namespace at the same install directory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Team Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Global Demo')
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'team',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('source conflict')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo' })
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Global Demo')
})
test('force rejects metadata explicitly owned by another installer', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Manual')
await writeManagedMetadata(skillDir)
const metadataPath = join(skillDir, '.skillhub', 'metadata.json')
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
metadata.source = 'manual'
await writeFile(metadataPath, JSON.stringify(metadata))
const metadataBefore = await readFile(metadataPath, 'utf-8')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('cannot verify SkillHub ownership')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Manual')
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBefore)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
const entries = await readdir(rootDir)
expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false)
expect(entries.some(name => name.includes('skillhub-install.lock'))).toBe(false)
})
test('force rejects a directory without installation metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'local.txt'), 'keep')
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true, home
})).rejects.toThrow('cannot verify SkillHub ownership')
expect(await readFile(join(skillDir, 'local.txt'), 'utf-8')).toBe('keep')
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('force rejects a different slug in installation metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Other')
await writeManagedMetadata(skillDir, {
registry: 'http://registry.test', namespace: 'global', slug: 'other'
})
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true, home
})).rejects.toThrow('source conflict')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Other')
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('revalidates ownership after download before replacing the target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') })
globalThis.fetch = (async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({
code: 0,
data: {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
versionId: 1,
fingerprint: skillFingerprint({ 'SKILL.md': '# New' }),
downloadUrl: '/download'
}
})
}
if (path.endsWith('/download')) {
await writeManagedMetadata(skillDir, {
registry: 'http://other-registry.test',
namespace: 'global',
slug: 'demo'
})
await writeFile(join(skillDir, 'SKILL.md'), '# Replaced during download')
return new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
)
}
return Response.json({ code: 404 }, { status: 404 })
}) as typeof fetch
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('source conflict')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Replaced during download')
const metadata = JSON.parse(await readFile(join(skillDir, '.skillhub', 'metadata.json'), 'utf-8'))
expect(metadata.registry).toBe('http://other-registry.test')
expect((await readdir(rootDir)).some(name => name.includes('skillhub-backup'))).toBe(false)
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rolls back every target when a later target changes source before commit', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const firstRoot = join(parent, 'a')
const secondRoot = join(parent, 'b')
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
for (const skillDir of [firstSkillDir, secondSkillDir]) {
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `# Old ${skillDir === firstSkillDir ? 'A' : 'B'}`)
await writeManagedMetadata(skillDir)
}
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [
{ agent: 'codex', rootDir: firstRoot, installDir: firstSkillDir, installedAt: '2026-09-01T00:00:00Z' },
{ agent: 'claude-code', rootDir: secondRoot, installDir: secondSkillDir, installedAt: '2026-09-01T00:00:00Z' }
]
}]
}))
const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') })
globalThis.fetch = (async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({ code: 0, data: {
namespace: 'global', slug: 'demo', version: '1.0.0', versionId: 1,
fingerprint: skillFingerprint({ 'SKILL.md': '# New' }), downloadUrl: '/download'
} })
}
if (path.endsWith('/download')) {
await writeManagedMetadata(secondSkillDir, {
registry: 'http://other-registry.test', namespace: 'global', slug: 'demo'
})
return new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
)
}
return Response.json({ code: 404 }, { status: 404 })
}) as typeof fetch
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' }
],
force: true,
home
})).rejects.toThrow('source conflict')
expect(await readFile(join(firstSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old A')
expect(await readFile(join(secondSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old B')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '0.1.0' })
expect(inventory.items[0].targets).toHaveLength(2)
for (const rootDir of [firstRoot, secondRoot]) {
const entries = await readdir(rootDir)
expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false)
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
}
})
test('rejects an active target lock and recovers a dead-process lock', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false, home
})).rejects.toThrow('install target is busy')
expect(await exists(join(rootDir, 'demo'))).toBe(false)
await rm(lockPath, { recursive: true })
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
await installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false, home
})
expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# New')
expect(await exists(lockPath)).toBe(false)
})
test('force keeps old installation and inventory when replacement extraction fails', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 }))
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}, null, 2))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('invalid zip central directory')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' })
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('force restores the old installation when inventory persistence fails', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const invalidHome = join(rootDir, 'home-is-a-file')
await writeFile(invalidHome, 'not a directory')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home: invalidHome
})).rejects.toThrow()
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
}))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('download exceeds maximum package size')
})
})