mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
Merge pull request #846 from iflytek/codex/fix/issue-843-version-string-20260910
fix(cli): preserve numeric-looking version strings
This commit is contained in:
commit
fd18d1957d
2 changed files with 232 additions and 2 deletions
|
|
@ -27,6 +27,39 @@ function toArray(val: string | string[] | undefined): string[] | undefined {
|
|||
return Array.isArray(val) ? val : [val]
|
||||
}
|
||||
|
||||
/** Read a string option before cac/mri coerces numeric-looking values to numbers. */
|
||||
function rawStringOption(argv: string[], name: string): string | undefined {
|
||||
const optionWithEquals = `${name}=`
|
||||
const end = argv.indexOf('--')
|
||||
const args = end === -1 ? argv : argv.slice(0, end)
|
||||
let value: string | undefined
|
||||
let occurrences = 0
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index]!
|
||||
if (argument === name) {
|
||||
occurrences += 1
|
||||
const candidate = args[index + 1]
|
||||
if (candidate === undefined || candidate.startsWith('-')) {
|
||||
throw new CliError(`option "${name}" value is missing`, EXIT.usage)
|
||||
}
|
||||
value = candidate
|
||||
index += 1
|
||||
} else if (argument.startsWith(optionWithEquals)) {
|
||||
occurrences += 1
|
||||
value = argument.slice(optionWithEquals.length)
|
||||
if (!value) {
|
||||
throw new CliError(`option "${name}" value is missing`, EXIT.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (occurrences > 1) {
|
||||
throw new CliError(`option "${name}" cannot be repeated`, EXIT.usage)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function runCommand(action: () => Promise<string>, json = false): Promise<void> {
|
||||
try {
|
||||
const output = await action()
|
||||
|
|
@ -246,7 +279,11 @@ cli
|
|||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
.action((slug: string, options: InstallCommandOptions & { agent?: string | string[] }) => {
|
||||
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
|
||||
return runCommand(() => installCommand(slug, {
|
||||
...options,
|
||||
version: rawStringOption(process.argv.slice(2), '--version'),
|
||||
agent: toArray(options.agent)
|
||||
}), Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
|
|
@ -262,7 +299,11 @@ cli
|
|||
.option('--json', 'Output JSON')
|
||||
.action((action: string, coordinate: string, options: SuiteCommandOptions & { agent?: string | string[] }) => {
|
||||
return runCommand(
|
||||
() => suiteCommand(action, coordinate, { ...options, agent: toArray(options.agent) }),
|
||||
() => suiteCommand(action, coordinate, {
|
||||
...options,
|
||||
version: rawStringOption(process.argv.slice(2), '--version'),
|
||||
agent: toArray(options.agent)
|
||||
}),
|
||||
Boolean(options.json)
|
||||
)
|
||||
})
|
||||
|
|
|
|||
189
cli/test/integration/version-option.test.ts
Normal file
189
cli/test/integration/version-option.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { mkdir } from 'node:fs/promises'
|
||||
import { 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'
|
||||
|
||||
const TIMESTAMP_VERSION = '20260910.021100'
|
||||
|
||||
let stopServer: (() => void) | undefined
|
||||
|
||||
afterEach(() => {
|
||||
stopServer?.()
|
||||
stopServer = undefined
|
||||
})
|
||||
|
||||
describe('--version parsing', () => {
|
||||
test('install preserves trailing zeros in a numeric-looking version', async () => {
|
||||
const env = await createTempHome()
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'timestamped',
|
||||
version: TIMESTAMP_VERSION,
|
||||
zipBytes: zipSync({ 'SKILL.md': strToU8('# timestamped') })
|
||||
}]
|
||||
})
|
||||
stopServer = registry.stop
|
||||
const installDir = join(env.cwd, 'skills')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli([
|
||||
'install', '@global/timestamped',
|
||||
'--version', TIMESTAMP_VERSION,
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION)
|
||||
})
|
||||
|
||||
test('install preserves trailing zeros with the --version=value form', async () => {
|
||||
const env = await createTempHome()
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'timestamped-equals',
|
||||
version: TIMESTAMP_VERSION,
|
||||
zipBytes: zipSync({ 'SKILL.md': strToU8('# timestamped equals') })
|
||||
}]
|
||||
})
|
||||
stopServer = registry.stop
|
||||
const installDir = join(env.cwd, 'skills-equals')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli([
|
||||
'install', '@global/timestamped-equals',
|
||||
`--version=${TIMESTAMP_VERSION}`,
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION)
|
||||
})
|
||||
|
||||
test('install preserves an ordinary text version', async () => {
|
||||
const env = await createTempHome()
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'text-version',
|
||||
version: 'release-a',
|
||||
zipBytes: zipSync({ 'SKILL.md': strToU8('# text version') })
|
||||
}]
|
||||
})
|
||||
stopServer = registry.stop
|
||||
const installDir = join(env.cwd, 'skills-text-version')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli([
|
||||
'install', '@global/text-version',
|
||||
'--version', 'release-a',
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.version).toBe('release-a')
|
||||
})
|
||||
|
||||
test('ignores --version after the option terminator', async () => {
|
||||
const env = await createTempHome()
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'latest',
|
||||
version: '1.0.0',
|
||||
zipBytes: zipSync({ 'SKILL.md': strToU8('# latest') })
|
||||
}]
|
||||
})
|
||||
stopServer = registry.stop
|
||||
const installDir = join(env.cwd, 'skills-latest')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli([
|
||||
'install', '@global/latest',
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--', '--version', TIMESTAMP_VERSION
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.version).toBeNull()
|
||||
})
|
||||
|
||||
test.each(['separate', 'equals'])('suite install preserves trailing zeros with %s syntax', async syntax => {
|
||||
const env = await createTempHome()
|
||||
const received = { version: null as string | null }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === '/.well-known/clawhub.json') {
|
||||
return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] })
|
||||
}
|
||||
if (url.pathname === '/api/v1/suites/global/starter-pack/install-plan') {
|
||||
received.version = url.searchParams.get('version')
|
||||
return Response.json({ code: 404, message: 'stop after capturing version' }, { status: 404 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
stopServer = () => server.stop(true)
|
||||
const installDir = join(env.cwd, 'suite-skills')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli([
|
||||
'suite', 'install', '@global/starter-pack',
|
||||
...(syntax === 'equals' ? [`--version=${TIMESTAMP_VERSION}`] : ['--version', TIMESTAMP_VERSION]),
|
||||
'--dir', installDir,
|
||||
'--registry', `http://localhost:${server.port}`,
|
||||
'--token', 'sk_ok'
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(received.version).toBe(TIMESTAMP_VERSION)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['a missing value', ['--version']],
|
||||
['a missing repeated value', ['--version', '1.0.0', '--version']],
|
||||
['repeated values', ['--version', '1.0.0', '--version', '2.0.0']],
|
||||
['an empty equals value', ['--version=']]
|
||||
])('rejects %s before contacting the registry', async (_description, versionArgs) => {
|
||||
const env = await createTempHome()
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'rejected',
|
||||
version: '1.0.0',
|
||||
zipBytes: zipSync({ 'SKILL.md': strToU8('# rejected') })
|
||||
}]
|
||||
})
|
||||
stopServer = registry.stop
|
||||
|
||||
const result = await runCli([
|
||||
'install', '@global/rejected',
|
||||
...versionArgs,
|
||||
'--json',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).toBe(5)
|
||||
expect(registry.received.resolves).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue