fix(cli): preserve sync safety guards under force

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-02 18:01:36 +08:00
parent 95864eb67c
commit 75bfb452d9
4 changed files with 97 additions and 22 deletions

View file

@ -302,6 +302,12 @@ skillhub sync push --all --namespace team-a --submit-review
The default workspace is `<cwd>/.agents/skills`. Pull never overwrites local changes unless `--force` is supplied. Remote removals are reported as `orphaned` and are retained unless `--prune` is supplied. Both destructive cases still require explicit flags.
Sync compares both the published version and package fingerprint. An exact match is `up-to-date`,
while a newer version is `update-available` even when its content is unchanged. An older remote
version, an unorderable version pair, or changed remote content without a version bump is `blocked`.
`--force` cannot bypass these release-safety checks; verify the release and use an explicit
`skillhub install` when replacement is intentional.
Workspace push is non-overwriting: an existing namespace/slug/version is reported as a conflict, including versions that are still uploaded or pending review. Other skills in the same `--all` run continue processing.
Namespace sync writes `.skillhub/namespace-sync.json` in the workspace and per-skill `.skillhub/metadata.json` files. These files contain the registry coordinate, published version, aggregate fingerprint, and file hashes used by `status` and `diff`.

View file

@ -47,10 +47,15 @@ export async function syncPullCommand(options: SyncPullOptions): Promise<string>
const output = renderPullResult(result, Boolean(options.json), Boolean(options.check))
if (result.failures.length > 0) {
process.stdout.write(`${output}\n`)
throw new CliError('namespace sync completed with failures', EXIT.generic, {
namespace: context.namespace,
failures: result.failures
})
const blocked = result.entries.filter(entry => entry.status === 'blocked')
throw new CliError(
blocked.length > 0 ? 'namespace sync blocked by remote version safety checks' : 'namespace sync completed with failures',
blocked.length > 0 ? EXIT.validation : EXIT.generic,
{
namespace: context.namespace,
failures: result.failures
}
)
}
return output
}

View file

@ -89,22 +89,7 @@ export async function inspectNamespaceWorkspace(options: {
}
const snapshot = await snapshotSkillDirectory(skillDir)
if (snapshot.fingerprint !== metadata.fingerprint) {
entries.push({
...baseEntry(remote, 'local-changed'),
localVersion: metadata.version,
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
})
continue
}
const versionOrder = compareSkillVersions(metadata.version, remote.version)
if (versionOrder === 'remote-newer') {
entries.push({
...baseEntry(remote, 'update-available'),
localVersion: metadata.version
})
continue
}
if (versionOrder === 'remote-older') {
entries.push({
...baseEntry(remote, 'blocked'),
@ -129,6 +114,21 @@ export async function inspectNamespaceWorkspace(options: {
})
continue
}
if (snapshot.fingerprint !== metadata.fingerprint) {
entries.push({
...baseEntry(remote, 'local-changed'),
localVersion: metadata.version,
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
})
continue
}
if (versionOrder === 'remote-newer') {
entries.push({
...baseEntry(remote, 'update-available'),
localVersion: metadata.version
})
continue
}
entries.push({ ...baseEntry(remote, 'up-to-date'), localVersion: metadata.version })
}

View file

@ -197,7 +197,7 @@ describe('sync command', () => {
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(1)
expect(pulled.exitCode).toBe(6)
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# original\n')
} finally {
registry.stop()
@ -229,7 +229,7 @@ describe('sync command', () => {
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(1)
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: '2.0.0',
@ -267,7 +267,7 @@ describe('sync command', () => {
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(1)
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: 'release-a',
@ -279,6 +279,70 @@ describe('sync command', () => {
}
})
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.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')