mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-13 23:11:06 +00:00
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:
commit
c33cd75e7a
11 changed files with 415 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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-'))
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -461,6 +461,9 @@ skillhub login --token <token> [--registry <url>] [--json]
|
|||
|
||||
保存 token 和 registry 配置。
|
||||
|
||||
CLI 以非破坏方式更新 `~/.skillhub/credentials.json` 和 `~/.skillhub/config.json`:只修改
|
||||
自己使用的 `tokens` 和 `registry` 字段,保留其他兼容工具写入的未知字段。
|
||||
|
||||
### logout
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue