fix(cli): validate complete suite upgrade snapshot

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 14:57:39 +08:00
parent 03c1537408
commit f62c1dbb75
2 changed files with 77 additions and 1 deletions

View file

@ -854,12 +854,32 @@ function assertSuiteSnapshotUnchanged(
before: InventorySuite | undefined,
locked: InventorySuite | undefined
): void {
if (before?.version === locked?.version && before?.fingerprint === locked?.fingerprint) return
if (suiteSnapshot(before) === suiteSnapshot(locked)) return
throw new CliError('installed Suite changed while waiting for target locks', EXIT.validation, {
next: 'run `skillhub suite check` and retry'
})
}
function suiteSnapshot(suite: InventorySuite | undefined): string {
if (!suite) return ''
const members = suite.members.map(member => ({
namespace: member.namespace,
slug: member.slug,
version: member.version,
fingerprint: member.fingerprint,
installDirs: member.installDirs.map(installDir => resolve(installDir)).sort()
})).sort((left, right) =>
`${left.namespace}\0${left.slug}`.localeCompare(`${right.namespace}\0${right.slug}`))
return JSON.stringify({
registry: suite.registry,
namespace: suite.namespace,
slug: suite.slug,
version: suite.version,
fingerprint: suite.fingerprint,
members
})
}
/** Serializes local install, upgrade, and remove operations for one Suite inventory identity. */
async function acquireSuiteOperationLock(
home: string | undefined,

View file

@ -865,6 +865,62 @@ describe('Suite local lifecycle', () => {
expect(inventory).toMatchObject({ items: [], suites: [] })
})
test('rejects stale upgrade targets after the same Suite is reinstalled elsewhere', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const originalRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-original-'))
const replacementRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-replacement-'))
const first = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir: originalRoot, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(first.plan, first.downloads)
})
const nextPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0' }
const upgradeClient = clientFor(nextPlan, first.downloads)
const fetchDetail = upgradeClient.suiteDetail.bind(upgradeClient)
let signalPlanRead: (() => void) | undefined
let releasePlan: (() => void) | undefined
const planRead = new Promise<void>((resolvePromise) => { signalPlanRead = resolvePromise })
const holdPlan = new Promise<void>((resolvePromise) => { releasePlan = resolvePromise })
upgradeClient.suiteDetail = async (...args) => {
signalPlanRead?.()
await holdPlan
return fetchDetail(...args)
}
const upgrading = upgradeSuite({
registry, namespace: 'global', slug: 'starter-pack', home, client: upgradeClient
})
await planRead
try {
await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home })
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'claude', rootDir: replacementRoot, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(first.plan, first.downloads)
})
} finally {
releasePlan?.()
}
await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks')
expect(await readdir(originalRoot)).toEqual([])
expect(await readFile(join(replacementRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' })
expect(inventory.suites[0].members.every((member: { installDirs: string[] }) =>
member.installDirs.every(dir => dir.startsWith(replacementRoot)))).toBe(true)
})
test('preserves a member modified after removal starts but before locked validation', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))