Merge remote-tracking branch 'origin/main' into feature/skill-suites-signed-final

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 13:55:06 +08:00
commit c33cd75e7a
11 changed files with 415 additions and 28 deletions

View file

@ -14,6 +14,9 @@ All notable CLI behavior changes are documented in this file.
### Fixed
- Preserve unknown fields in shared `~/.skillhub/config.json` and `credentials.json` files, and
treat a compatible credentials document without first-party `tokens` as logged out instead of
failing. Login and logout now modify only the first-party registry state.
- Return structured JSON from `help --json` and topic help, report unknown help topics as usage
errors, and support `--version` / `-v` alongside the existing `version` command.
- Report successful publish and sync push requests as submissions, preserving the registry's raw

View file

@ -86,6 +86,10 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry`
fields and preserves unknown fields written by other compatible tools. This allows tools that share
the `~/.skillhub` directory to keep independently named state in the same JSON documents.
### Check Current Identity
```bash

View file

@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { chmod, lstat, mkdir } from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import { chmod, lstat, mkdir, readdir, rename, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { lock } from 'proper-lockfile'
@ -7,23 +7,203 @@ import { canonicalizeExistingPath } from '../platform/paths'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
const ACQUISITION_GATE_MAX_POLL_MS = 100
/** Serializes every local lifecycle mutation for one Skill target directory. */
export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise<void>> {
const lockPath = await skillTargetLockPath(rootDir, slug)
// proper-lockfile's stale deletion is not serialized. Gate only the acquisition attempt so two
// recoverers cannot remove and replace the same target lock concurrently.
const acquisitionGatePath = `${lockPath}.acquire`
let releaseAcquisitionGate: () => Promise<void>
try {
return await lock(lockPath, {
lockfilePath: lockPath,
realpath: false,
stale: 10_000,
update: 3_000,
retries: 0
})
releaseAcquisitionGate = await acquireAcquisitionGate(acquisitionGatePath)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') {
throw targetBusyError(rootDir, slug)
}
if (hasErrorCode(error, 'EEXIST')) throw targetBusyError(rootDir, slug)
throw error
}
let releaseTarget: () => Promise<void>
try {
releaseTarget = await acquireTargetLock(lockPath)
} catch (operationError) {
try {
await releaseAcquisitionGate()
} catch (cleanupError) {
throw new AggregateError([operationError, cleanupError], 'target lock acquisition and gate cleanup both failed')
}
if (hasErrorCode(operationError, 'ELOCKED')) throw targetBusyError(rootDir, slug)
throw operationError
}
try {
await releaseAcquisitionGate()
} catch (gateCleanupError) {
try {
await releaseTarget()
} catch (targetCleanupError) {
throw new AggregateError([gateCleanupError, targetCleanupError], 'target and acquisition gate cleanup both failed')
}
throw gateCleanupError
}
return releaseTarget
}
function acquireTargetLock(lockPath: string): Promise<() => Promise<void>> {
return lock(lockPath, {
lockfilePath: lockPath,
realpath: false,
stale: 10_000,
update: 3_000,
retries: 0
})
}
async function acquireAcquisitionGate(gatePath: string): Promise<() => Promise<void>> {
await ensureAcquisitionGateDirectory(gatePath)
// A per-target Lamport bakery queue avoids deleting a shared stale gate. Every removable path
// contains a nonce and is owned by one PID, so crash recovery cannot unlink a replacement owner.
const contenderId = `${process.pid}-${randomUUID()}`
const choosingPath = join(gatePath, `choosing.${contenderId}`)
let ticketPath: string | null = null
let ticket: number | null = null
await writeFile(choosingPath, '', { flag: 'wx', mode: 0o600 })
try {
const { tickets } = await readGateState(gatePath, contenderId)
ticket = Math.max(0, ...tickets.map(contender => contender.ticket)) + 1
ticketPath = join(gatePath, `ticket.${ticket}.${contenderId}`)
await rename(choosingPath, ticketPath)
await waitForAcquisitionTurn(gatePath, { id: contenderId, ticket })
} catch (operationError) {
const cleanupErrors = await removeContenderFiles(choosingPath, ...(ticketPath === null ? [] : [ticketPath]))
if (cleanupErrors.length > 0) {
throw new AggregateError([operationError, ...cleanupErrors], 'acquisition gate attempt and cleanup both failed')
}
throw operationError
}
return async () => {
try {
await unlink(ticketPath!)
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) throw compromisedGateError(ticketPath!)
throw error
}
}
}
async function ensureAcquisitionGateDirectory(gatePath: string): Promise<void> {
try {
await mkdir(gatePath, { mode: 0o700 })
} catch (error) {
if (!hasErrorCode(error, 'EEXIST')) throw error
}
const details = await lstat(gatePath)
if (!details.isDirectory() || details.isSymbolicLink()) {
throw new Error(`unsafe SkillHub CLI acquisition gate directory: ${gatePath}`)
}
}
interface AcquisitionContender {
id: string
ticket: number
}
interface AcquisitionGateState {
tickets: AcquisitionContender[]
hasLiveChoosing: boolean
}
async function readGateState(gatePath: string, contenderId: string): Promise<AcquisitionGateState> {
const entries = await readdir(gatePath)
const tickets: AcquisitionContender[] = []
let hasLiveChoosing = false
for (const entry of entries) {
const path = join(gatePath, entry)
const ticketMatch = /^ticket\.(\d+)\.(\d+)-([^.]+)$/.exec(entry)
if (ticketMatch) {
const ticket = Number(ticketMatch[1])
const pid = Number(ticketMatch[2])
if (!isProcessAlive(pid)) {
await unlinkIfPresent(path)
continue
}
if (!Number.isSafeInteger(ticket) || ticket < 1) throw compromisedGateError(path)
tickets.push({ id: `${ticketMatch[2]}-${ticketMatch[3]}`, ticket })
continue
}
const choosingMatch = /^choosing\.(\d+)-([^.]+)$/.exec(entry)
if (choosingMatch && `${choosingMatch[1]}-${choosingMatch[2]}` !== contenderId) {
if (!isProcessAlive(Number(choosingMatch[1]))) {
await unlinkIfPresent(path)
} else {
hasLiveChoosing = true
}
}
}
return { tickets, hasLiveChoosing }
}
async function waitForAcquisitionTurn(
gatePath: string,
contender: AcquisitionContender
): Promise<void> {
let delayMs = 5
for (;;) {
const state = await readGateState(gatePath, contender.id)
const hasEarlierTicket = state.tickets.some(candidate => compareContenders(candidate, contender) < 0)
if (!state.hasLiveChoosing && !hasEarlierTicket) return
await new Promise(resolve => setTimeout(resolve, delayMs))
delayMs = Math.min(delayMs * 2, ACQUISITION_GATE_MAX_POLL_MS)
}
}
function compareContenders(left: AcquisitionContender, right: AcquisitionContender): number {
if (left.ticket !== right.ticket) return left.ticket < right.ticket ? -1 : 1
if (left.id === right.id) return 0
return left.id < right.id ? -1 : 1
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return !hasErrorCode(error, 'ESRCH')
}
}
async function removeContenderFiles(...paths: string[]): Promise<Error[]> {
const errors: Error[] = []
for (const path of paths) {
try {
await unlink(path)
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) errors.push(error instanceof Error ? error : new Error(String(error)))
}
}
return errors
}
async function unlinkIfPresent(path: string): Promise<void> {
try {
await unlink(path)
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) throw error
}
}
function compromisedGateError(gatePath: string): Error {
return Object.assign(new Error(`SkillHub CLI acquisition gate was replaced: ${gatePath}`), {
code: 'ECOMPROMISED'
})
}
function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code
}
export async function skillTargetLockPath(rootDir: string, slug: string): Promise<string> {

View file

@ -2,7 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths'
export interface CliConfig {
export interface CliConfig extends Record<string, unknown> {
registry?: string
defaultAgent?: string
lastUpdateCheckAt?: string

View file

@ -2,8 +2,8 @@ import { readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { joinPath, userStateDir, ensureDir, applyCredentialPermissions, pathExists } from '../platform/paths'
interface CredentialsFile {
tokens: Record<string, string>
interface CredentialsFile extends Record<string, unknown> {
tokens?: Record<string, unknown>
}
export class CredentialsStore {
@ -14,18 +14,22 @@ export class CredentialsStore {
}
async read(): Promise<CredentialsFile> {
if (!(await pathExists(this.path))) return { tokens: {} }
if (!(await pathExists(this.path))) return {}
return JSON.parse(await readFile(this.path, 'utf-8')) as CredentialsFile
}
async getToken(registry: string): Promise<string | undefined> {
return (await this.read()).tokens[registry]
const token = (await this.read()).tokens?.[registry]
return typeof token === 'string' ? token : undefined
}
async setToken(registry: string, token: string): Promise<void> {
const current = await this.read()
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify({ tokens: { ...current.tokens, [registry]: token } }, null, 2))
await writeFile(this.path, JSON.stringify({
...current,
tokens: { ...current.tokens, [registry]: token }
}, null, 2))
await applyCredentialPermissions(this.path)
}
@ -34,7 +38,7 @@ export class CredentialsStore {
const tokens = { ...current.tokens }
delete tokens[registry]
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify({ tokens }, null, 2))
await writeFile(this.path, JSON.stringify({ ...current, tokens }, null, 2))
await applyCredentialPermissions(this.path)
}
}

View file

@ -30,6 +30,42 @@ describe('auth commands', () => {
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toMatchObject({ tokens: { [registry.url]: 'sk_ok' } })
})
test('login and logout preserve compatible third-party state', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
const thirdPartyUser = { token: 'third-party-token', host: 'https://api.skillhub.cn' }
await Bun.write(`${env.home}/.skillhub/config.json`, JSON.stringify({
self_update_url: 'https://skillhub.example.com/version.json',
auto_self_upgrade: false
}))
await Bun.write(`${env.home}/.skillhub/credentials.json`, JSON.stringify({ user: thirdPartyUser }))
const login = await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(login.exitCode).toBe(0)
expect(await Bun.file(`${env.home}/.skillhub/config.json`).json()).toEqual({
self_update_url: 'https://skillhub.example.com/version.json',
auto_self_upgrade: false,
registry: registry.url
})
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toEqual({
user: thirdPartyUser,
tokens: { [registry.url]: 'sk_ok' }
})
const logout = await runCli(['logout', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(logout.exitCode).toBe(0)
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toEqual({
user: thirdPartyUser,
tokens: {}
})
})
test('login fails with invalid token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })

View file

@ -1,4 +1,4 @@
import { access, chmod, lstat, mkdir, mkdtemp, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { access, chmod, lstat, mkdir, mkdtemp, readdir, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -27,6 +27,21 @@ async function waitForFile(path: string): Promise<void> {
throw new Error(`timed out waiting for ${path}`)
}
async function waitForExitCount(
processes: Array<{ exited: Promise<number> }>,
count: number
): Promise<number[]> {
const exitCodes: number[] = []
for (const process of processes) {
void process.exited.then(exitCode => exitCodes.push(exitCode))
}
for (let attempt = 0; attempt < 500; attempt++) {
if (exitCodes.length >= count) return exitCodes
await Bun.sleep(10)
}
throw new Error(`timed out waiting for ${count} lock contenders to exit`)
}
describe('skill target lifecycle lock', () => {
test('creates or repairs a private lock root and rejects unsafe roots', async () => {
const parent = await mkdtemp(join(tmpdir(), 'skillhub-lock-root-'))
@ -65,12 +80,14 @@ describe('skill target lifecycle lock', () => {
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const acquisitionGatePath = `${lockPath}.acquire`
const worker = fileURLToPath(new URL('../../helpers/target-lock-worker.ts', import.meta.url))
const bunPath = (await Bun.which('bun')) ?? process.execPath
const acquiredPath = join(rootDir, 'acquired')
const releasePath = join(rootDir, 'release')
const startPath = join(rootDir, 'start')
const readyPaths = [join(rootDir, 'ready-0'), join(rootDir, 'ready-1')]
const workerCount = 8
const readyPaths = Array.from({ length: workerCount }, (_, index) => join(rootDir, `ready-${index}`))
const processes = readyPaths.map(readyPath => Bun.spawn({
cmd: [bunPath, worker, rootDir, 'demo', readyPath, startPath, acquiredPath, releasePath],
@ -81,11 +98,8 @@ describe('skill target lifecycle lock', () => {
await Promise.all(readyPaths.map(waitForFile))
await writeFile(startPath, 'start')
await waitForFile(acquiredPath)
const loserExitCode = await Promise.race([
...processes.map(process => process.exited),
Bun.sleep(5_000).then(() => { throw new Error('timed out waiting for the lock loser') })
])
expect(loserExitCode).toBe(4)
const loserExitCodes = await waitForExitCount(processes, workerCount - 1)
expect(loserExitCodes).toEqual(Array(workerCount - 1).fill(4))
} finally {
try {
await writeFile(releasePath, 'release')
@ -106,14 +120,83 @@ describe('skill target lifecycle lock', () => {
stderr: (await new Response(process.stderr).text()).trim()
})))
expect(results.map(result => result.exitCode).sort()).toEqual([0, 4])
expect(results.map(result => result.exitCode).sort()).toEqual([0, ...Array(workerCount - 1).fill(4)])
expect(results.filter(result => result.stdout === 'acquired')).toHaveLength(1)
for (const loser of results.filter(result => result.exitCode === 4)) {
expect(loser.stderr).toContain('install target is busy')
}
expect(await exists(lockPath)).toBe(false)
expect(await readdir(acquisitionGatePath)).toEqual([])
const releaseAfterContention = await acquireSkillTargetLock(rootDir, 'demo')
await releaseAfterContention()
if (process.platform !== 'win32') {
expect((await lstat(dirname(lockPath))).mode & 0o077).toBe(0)
}
}, 15_000)
test('does not recover a live acquisition contender solely because its file is old', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-gate-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const liveContenderPath = join(acquisitionGatePath, `choosing.${process.pid}-suspended`)
await writeFile(liveContenderPath, '')
const staleTime = new Date(Date.now() - 60_000)
await utimes(liveContenderPath, staleTime, staleTime)
const acquisition = acquireSkillTargetLock(rootDir, 'demo')
const state = await Promise.race([
acquisition.then(() => 'acquired', () => 'rejected'),
Bun.sleep(50).then(() => 'waiting')
])
expect(state).toBe('waiting')
expect(await exists(liveContenderPath)).toBe(true)
await unlink(liveContenderPath)
const release = await acquisition
await release()
})
test('does not pass a live acquisition ticket', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-ticket-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const liveTicketPath = join(acquisitionGatePath, `ticket.1.${process.pid}-suspended`)
await writeFile(liveTicketPath, '')
const acquisition = acquireSkillTargetLock(rootDir, 'demo')
const state = await Promise.race([
acquisition.then(() => 'acquired', () => 'rejected'),
Bun.sleep(50).then(() => 'waiting')
])
expect(state).toBe('waiting')
expect(await exists(liveTicketPath)).toBe(true)
await unlink(liveTicketPath)
const release = await acquisition
await release()
})
test('recovers acquisition contenders whose owner process exited', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-dead-gate-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const bunPath = (await Bun.which('bun')) ?? process.execPath
const exitedOwner = Bun.spawn({ cmd: [bunPath, '-e', ''], stdout: 'ignore', stderr: 'ignore' })
const deadPid = exitedOwner.pid
expect(await exitedOwner.exited).toBe(0)
await writeFile(join(acquisitionGatePath, `choosing.${deadPid}-abandoned`), '')
await writeFile(join(acquisitionGatePath, `ticket.1.${deadPid}-abandoned`), '')
const release = await acquireSkillTargetLock(rootDir, 'demo')
await release()
expect(await exists(lockPath)).toBe(false)
expect(await readdir(acquisitionGatePath)).toEqual([])
})
test('keeps one lock identity when a symlink target is removed', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-root-'))
const linkedDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-value-'))

View file

@ -42,4 +42,23 @@ describe('ConfigStore', () => {
expect(config.registry).toBe('https://new.com')
expect(config.defaultAgent).toBe('codex')
})
test('setRegistry() preserves third-party and unknown fields', async () => {
const home = await makeTempHome()
const store = new ConfigStore(home)
await store.write({
self_update_url: 'https://skillhub.example.com/version.json',
auto_self_upgrade: false,
futureField: { enabled: true }
})
await store.setRegistry('https://registry.example.com')
expect(await store.read()).toEqual({
self_update_url: 'https://skillhub.example.com/version.json',
auto_self_upgrade: false,
futureField: { enabled: true },
registry: 'https://registry.example.com'
})
})
})

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { normalize } from 'path'
import { readFile } from 'node:fs/promises'
import { normalize } from 'node:path'
import { createTempHome } from '../../helpers/temp-env'
import { CredentialsStore } from '../../../src/stores/credentials-store'
@ -12,4 +13,52 @@ describe('CredentialsStore', () => {
expect(normalize(store.path)).toBe(normalize(`${env.home}/.skillhub/credentials.json`))
expect(await Bun.file(`${env.cwd}/credentials.json`).exists()).toBe(false)
})
test('treats a third-party credentials file without tokens as logged out', async () => {
const env = await createTempHome()
const store = new CredentialsStore(env.home)
await Bun.write(store.path, JSON.stringify({
user: { token: 'third-party-token', host: 'https://api.skillhub.cn' }
}))
expect(await store.getToken('https://registry.example.com')).toBeUndefined()
})
test('setToken preserves third-party and unknown fields', async () => {
const env = await createTempHome()
const store = new CredentialsStore(env.home)
await Bun.write(store.path, JSON.stringify({
user: { token: 'third-party-token', host: 'https://api.skillhub.cn' },
futureField: { enabled: true }
}))
await store.setToken('https://registry.example.com', 'sk_test')
const saved = JSON.parse(await readFile(store.path, 'utf-8'))
expect(saved).toEqual({
user: { token: 'third-party-token', host: 'https://api.skillhub.cn' },
futureField: { enabled: true },
tokens: { 'https://registry.example.com': 'sk_test' }
})
})
test('deleteToken removes only the selected first-party token', async () => {
const env = await createTempHome()
const store = new CredentialsStore(env.home)
await Bun.write(store.path, JSON.stringify({
user: { token: 'third-party-token', host: 'https://api.skillhub.cn' },
tokens: {
'https://registry-a.example.com': 'sk_a',
'https://registry-b.example.com': 'sk_b'
}
}))
await store.deleteToken('https://registry-a.example.com')
const saved = JSON.parse(await readFile(store.path, 'utf-8'))
expect(saved).toEqual({
user: { token: 'third-party-token', host: 'https://api.skillhub.cn' },
tokens: { 'https://registry-b.example.com': 'sk_b' }
})
})
})

View file

@ -83,6 +83,9 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry`
fields and preserves unknown fields written by other compatible tools.
When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message.
### Check Current Identity
@ -495,6 +498,9 @@ skillhub login --token <token> [--registry <url>] [--json]
Save token and registry configuration.
The CLI preserves unknown fields in the shared configuration and credentials documents when it
updates its own `registry` and `tokens` fields.
### logout
```bash

View file

@ -461,6 +461,9 @@ skillhub login --token <token> [--registry <url>] [--json]
保存 token 和 registry 配置。
CLI 以非破坏方式更新 `~/.skillhub/credentials.json``~/.skillhub/config.json`:只修改
自己使用的 `tokens``registry` 字段,保留其他兼容工具写入的未知字段。
### logout
```bash