fix(ISSUE-64): quote legacy install command versions

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-08 10:55:41 +08:00
parent 4f193a5eba
commit 9815cd7e91
3 changed files with 79 additions and 5 deletions

View file

@ -74,6 +74,15 @@ describe('install-command', () => {
)
})
it('shell-quotes historical supported versions that include spaces', () => {
expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '1.0.0 beta')).toBe(
"npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version '1.0.0 beta'",
)
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '1.0.0 beta')).toBe(
"npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version '1.0.0 beta'",
)
})
it('builds a one-line SkillHub npx command for the global namespace', () => {
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
@ -92,12 +101,12 @@ describe('install-command', () => {
)
})
it('omits unsafe versions from install commands', () => {
it('shell-quotes potentially hostile versions instead of silently dropping them', () => {
expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0 && whoami')).toBe(
'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn',
"npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version '0.9.0 && whoami'",
)
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0 && whoami')).toBe(
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn',
"npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version '0.9.0 && whoami'",
)
})

View file

@ -12,6 +12,8 @@ interface InstallCommandProps {
}
const SAFE_VERSION_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/
const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u
const SHELL_SINGLE_QUOTE_ESCAPE = `'"'"'`
export function buildInstallTarget(namespace: string, slug: string): string {
return namespace === 'global' ? slug : `${namespace}--${slug}`
@ -31,11 +33,25 @@ export function getBaseUrl(): string {
return `${window.location.protocol}//${window.location.host}`
}
function escapeShellArgument(value: string): string | null {
if (!value || CONTROL_CHARACTER_PATTERN.test(value)) {
return null
}
if (SAFE_VERSION_PATTERN.test(value)) {
return value
}
return `'${value.replace(/'/g, SHELL_SINGLE_QUOTE_ESCAPE)}'`
}
function buildVersionedCommand(command: string, version?: string): string {
if (!version || !SAFE_VERSION_PATTERN.test(version)) {
if (!version) {
return command
}
return `${command} --version ${version}`
const escapedVersion = escapeShellArgument(version)
if (!escapedVersion) {
return command
}
return `${command} --version ${escapedVersion}`
}
export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string {

View file

@ -392,6 +392,55 @@ describe('SkillDetailPage', () => {
expect(html).toContain('install:0.9.0')
})
it('passes historical published versions with spaces through to the install command', () => {
detailSearchState = { returnTo: '/dashboard/skills', version: '1.0.0 beta' }
useSkillVersionsMock.mockReturnValue({
data: [
{
id: 10,
version: '1.0.0',
status: 'PUBLISHED',
changelog: '',
fileCount: 1,
totalSize: 12,
publishedAt: '2026-03-20T00:00:00Z',
downloadAvailable: true,
},
{
id: 9,
version: '1.0.0 beta',
status: 'PUBLISHED',
changelog: '',
fileCount: 1,
totalSize: 12,
publishedAt: '2026-03-19T00:00:00Z',
downloadAvailable: true,
},
],
})
useSkillVersionDetailMock.mockImplementation((_namespace: string, _slug: string, version?: string) => ({
data: version === '1.0.0 beta'
? {
id: 9,
version: '1.0.0 beta',
status: 'PUBLISHED',
changelog: '',
fileCount: 1,
totalSize: 12,
publishedAt: '2026-03-19T00:00:00Z',
parsedMetadataJson: '{}',
manifestJson: '[]',
complianceMappings: [],
}
: undefined,
}))
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(useSkillVersionDetailMock).toHaveBeenCalledWith('global', 'demo-skill', '1.0.0 beta', true)
expect(html).toContain('install:1.0.0 beta')
})
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({