mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat(cli): add namespace workspace sync
Signed-off-by: mrLi <50289372+15258725278@users.noreply.github.com>
This commit is contained in:
parent
d2403bb591
commit
84feb38931
18 changed files with 1182 additions and 32 deletions
|
|
@ -32,6 +32,9 @@ skillhub list
|
|||
|
||||
# Publish skill
|
||||
skillhub publish ./my-skill --namespace myspace
|
||||
|
||||
# Synchronize a team workspace
|
||||
skillhub sync pull --namespace myspace
|
||||
```
|
||||
|
||||
## 🌐 Registry Configuration
|
||||
|
|
@ -229,11 +232,45 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
|
|||
"namespace": "global",
|
||||
"slug": "pdf-parser",
|
||||
"version": "1.0.0",
|
||||
"fingerprint": "sha256:...",
|
||||
"source": "skillhub",
|
||||
"agent": "codex",
|
||||
"installedAt": "2026-04-28T06:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Namespace Workspaces
|
||||
|
||||
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
|
||||
|
||||
```bash
|
||||
# Pull new and updated skills into ./.agents/skills
|
||||
skillhub sync pull --namespace team-a
|
||||
|
||||
# Use an explicit workspace directory
|
||||
skillhub sync pull --namespace team-a --dir ./.claude/skills
|
||||
|
||||
# Check without downloading
|
||||
skillhub sync pull --namespace team-a --check
|
||||
|
||||
# Show local edits and remote updates
|
||||
skillhub sync status --namespace team-a --json
|
||||
skillhub sync diff --namespace team-a
|
||||
|
||||
# Remove only unchanged SkillHub-managed skills that no longer exist remotely
|
||||
skillhub sync pull --namespace team-a --prune
|
||||
|
||||
# Validate and upload every local skill for review
|
||||
skillhub sync push --all --namespace team-a --dry-run
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## 📋 Local Management
|
||||
|
||||
### List Installed Skills
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@astron-team/skillhub",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"description": "Manage and install skills for AI coding agents",
|
||||
"keywords": [
|
||||
"skillhub",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,30 @@ export interface PublishResponse {
|
|||
slug: string
|
||||
version: string
|
||||
visibility: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncItem {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
versionId: number
|
||||
fingerprint: string
|
||||
updatedAt: string
|
||||
visibility: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncResponse {
|
||||
items: NamespaceSyncItem[]
|
||||
nextCursor?: string | null
|
||||
}
|
||||
|
||||
export interface SubmitReviewResponse {
|
||||
skillId: number
|
||||
versionId: number
|
||||
action: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface DryRunResponse {
|
||||
|
|
@ -80,6 +104,12 @@ export class SkillHubClient {
|
|||
return this.getJson(`/skills/${namespace}/${slug}/resolve${params}`)
|
||||
}
|
||||
|
||||
async listNamespaceSkills(namespace: string, cursor?: string, limit = 100): Promise<NamespaceSyncResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
return this.getJson(`/namespaces/${encodeURIComponent(namespace)}/skills?${params}`)
|
||||
}
|
||||
|
||||
async downloadUrl(namespace: string, slug: string, version?: string): Promise<string> {
|
||||
if (version) {
|
||||
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
|
||||
|
|
@ -105,10 +135,17 @@ export class SkillHubClient {
|
|||
return this.deleteJson(`/skills/${namespace}/${slug}`)
|
||||
}
|
||||
|
||||
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
|
||||
async publish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<PublishResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish`, {
|
||||
|
|
@ -122,10 +159,17 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
async validatePublish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<DryRunResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
|
||||
|
|
@ -139,6 +183,28 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
async submitReview(
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'
|
||||
): Promise<SubmitReviewResponse> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(
|
||||
`${this.registry}/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/submit-review`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...this.headers(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version, targetVisibility })
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<SubmitReviewResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ export const commands = {
|
|||
'skillhub install pdf-parser --scope project --agent codex'
|
||||
]
|
||||
},
|
||||
sync: {
|
||||
summary: 'Synchronize and maintain namespace workspaces',
|
||||
usage: 'skillhub sync <pull|status|diff|push> [options]',
|
||||
examples: [
|
||||
'skillhub sync pull --namespace team-a',
|
||||
'skillhub sync status --namespace team-a --json',
|
||||
'skillhub sync push --all --namespace team-a --submit-review'
|
||||
]
|
||||
},
|
||||
list: {
|
||||
summary: 'List local installs',
|
||||
usage: 'skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]',
|
||||
|
|
|
|||
179
cli/src/commands/sync.ts
Normal file
179
cli/src/commands/sync.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { join, resolve } from 'node:path'
|
||||
import { ConfigStore } from '../stores/config-store'
|
||||
import { CredentialsStore } from '../stores/credentials-store'
|
||||
import { SkillHubClient } from '../clients/skillhub-client'
|
||||
import { resolveRegistry, resolveToken } from '../services/registry-service'
|
||||
import {
|
||||
discoverSkillDirectories,
|
||||
inspectNamespaceWorkspace,
|
||||
pullNamespace,
|
||||
pushSkills,
|
||||
type PullResult,
|
||||
type PushResultItem,
|
||||
type SyncStatusEntry
|
||||
} from '../services/sync-service'
|
||||
import { CliError } from '../shared/errors'
|
||||
import { EXIT } from '../shared/constants'
|
||||
|
||||
export interface SyncCommonOptions {
|
||||
namespace?: string
|
||||
dir?: string
|
||||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPullOptions extends SyncCommonOptions {
|
||||
check?: boolean
|
||||
prune?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPushOptions extends SyncCommonOptions {
|
||||
all?: boolean
|
||||
visibility?: string
|
||||
dryRun?: boolean
|
||||
submitReview?: boolean
|
||||
}
|
||||
|
||||
export async function syncPullCommand(options: SyncPullOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await pullNamespace({
|
||||
...context,
|
||||
check: Boolean(options.check),
|
||||
prune: Boolean(options.prune),
|
||||
force: Boolean(options.force)
|
||||
})
|
||||
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
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export async function syncStatusCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
return renderStatusEntries(context.namespace, context.rootDir, result.entries, Boolean(options.json))
|
||||
}
|
||||
|
||||
export async function syncDiffCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
const changed = result.entries.filter(entry => entry.status !== 'up-to-date')
|
||||
if (options.json) {
|
||||
return JSON.stringify({ ok: true, namespace: context.namespace, rootDir: context.rootDir, items: changed })
|
||||
}
|
||||
if (changed.length === 0) return `No differences for namespace ${context.namespace}.`
|
||||
return changed.flatMap(entry => {
|
||||
const lines = [`${entry.status.padEnd(16)} ${entry.slug}`]
|
||||
for (const path of entry.changedFiles) lines.push(` ${path}`)
|
||||
if (entry.reason) lines.push(` ${entry.reason}`)
|
||||
return lines
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
export async function syncPushCommand(path: string | undefined, options: SyncPushOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
if (path && options.all) {
|
||||
throw new CliError('path cannot be combined with --all', EXIT.usage)
|
||||
}
|
||||
if (!path && !options.all) {
|
||||
throw new CliError('provide a skill path or pass --all', EXIT.usage)
|
||||
}
|
||||
|
||||
const visibility = normalizeVisibility(options.visibility ?? 'namespace-only')
|
||||
if (options.submitReview && visibility === 'PRIVATE') {
|
||||
throw new CliError('--submit-review requires public or namespace-only visibility', EXIT.usage)
|
||||
}
|
||||
const paths = options.all
|
||||
? await discoverSkillDirectories(context.rootDir)
|
||||
: [resolve(path!)]
|
||||
if (paths.length === 0) {
|
||||
throw new CliError(`no skill directories found in ${context.rootDir}`, EXIT.filesystem, { path: context.rootDir })
|
||||
}
|
||||
|
||||
const results = await pushSkills({
|
||||
client: context.client,
|
||||
namespace: context.namespace,
|
||||
paths,
|
||||
visibility,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
submitReview: Boolean(options.submitReview)
|
||||
})
|
||||
const output = renderPushResults(context.namespace, results, Boolean(options.json), Boolean(options.dryRun))
|
||||
if (results.some(item => item.action === 'failed')) {
|
||||
process.stdout.write(`${output}\n`)
|
||||
throw new CliError('one or more skills failed to push', EXIT.validation, {
|
||||
namespace: context.namespace,
|
||||
failed: results.filter(item => item.action === 'failed')
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
async function resolveSyncContext(options: SyncCommonOptions): Promise<{
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
}> {
|
||||
const configStore = new ConfigStore()
|
||||
const credentialsStore = new CredentialsStore()
|
||||
const registry = resolveRegistry(options, process.env, await configStore.read())
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
if (!token) {
|
||||
throw new CliError('authentication required for namespace sync', EXIT.auth, { next: 'run `skillhub login`' })
|
||||
}
|
||||
const namespace = options.namespace ?? 'global'
|
||||
const rootDir = resolve(options.dir ?? join(process.cwd(), '.agents', 'skills'))
|
||||
return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir }
|
||||
}
|
||||
|
||||
function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
|
||||
if (json) {
|
||||
return JSON.stringify({ ok: result.failures.length === 0, check, ...result })
|
||||
}
|
||||
const lines = [
|
||||
`${check ? 'Checked' : 'Synchronized'} ${result.namespace} in ${result.rootDir}`,
|
||||
...result.actions.map(item => `${item.action.padEnd(10)} ${item.slug}`),
|
||||
...result.entries
|
||||
.filter(entry => !result.actions.some(action => action.slug === entry.slug))
|
||||
.map(entry => `${entry.status.padEnd(16)} ${entry.slug}`),
|
||||
...result.failures.map(item => `failed ${item.slug}: ${item.message}`)
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderStatusEntries(namespace: string, rootDir: string, entries: SyncStatusEntry[], json: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: true, namespace, rootDir, items: entries })
|
||||
if (entries.length === 0) return `No installable skills found in namespace ${namespace}.`
|
||||
return entries.map(entry => {
|
||||
const versions = entry.remoteVersion
|
||||
? ` local=${entry.localVersion ?? '-'} remote=${entry.remoteVersion}`
|
||||
: ` local=${entry.localVersion ?? '-'}`
|
||||
return `${entry.status.padEnd(16)} ${entry.slug}${versions}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function renderPushResults(namespace: string, results: PushResultItem[], json: boolean, dryRun: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: results.every(item => item.action !== 'failed'), namespace, dryRun, items: results })
|
||||
return results.map(item => {
|
||||
const coordinate = item.slug ? `${namespace}/${item.slug}${item.version ? `@${item.version}` : ''}` : item.path
|
||||
const detail = item.errors?.length ? `: ${item.errors.join('; ')}` : ''
|
||||
return `${item.action.padEnd(16)} ${coordinate}${detail}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function normalizeVisibility(value: string): 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' {
|
||||
const normalized = value.toUpperCase().replace(/-/g, '_')
|
||||
if (normalized !== 'PUBLIC' && normalized !== 'NAMESPACE_ONLY' && normalized !== 'PRIVATE') {
|
||||
throw new CliError('visibility must be public, namespace-only, or private', EXIT.usage)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
|
||||
export const PKG_NAME = "@astron-team/skillhub"
|
||||
export const PKG_VERSION = "0.1.9"
|
||||
export const PKG_VERSION = "0.1.10"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { logoutCommand } from './commands/logout'
|
|||
import { publishCommand, type PublishCommandOptions } from './commands/publish'
|
||||
import { removeCommand, type RemoveCommandOptions } from './commands/remove'
|
||||
import { searchCommand } from './commands/search'
|
||||
import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync'
|
||||
import { updateCommand } from './commands/update'
|
||||
import { versionCommand } from './commands/version'
|
||||
import { whoamiCommand } from './commands/whoami'
|
||||
import { EXIT } from './shared/constants'
|
||||
import { CliError } from './shared/errors'
|
||||
import { renderError } from './shared/output'
|
||||
|
||||
|
|
@ -245,6 +247,37 @@ cli
|
|||
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('sync <action> [path]', 'Synchronize and maintain a namespace workspace')
|
||||
.option('--namespace <slug>', 'Namespace', { default: 'global' })
|
||||
.option('--dir <path>', 'Skill workspace directory')
|
||||
.option('--check', 'Show changes without downloading')
|
||||
.option('--prune', 'Remove managed local skills missing remotely')
|
||||
.option('--force', 'Overwrite local changes')
|
||||
.option('--all', 'Push every skill directory in the workspace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)', { default: 'namespace-only' })
|
||||
.option('--dry-run', 'Validate without uploading')
|
||||
.option('--submit-review', 'Submit an uploaded version for review when required')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
.action((action: string, path: string | undefined, options: SyncPullOptions & SyncPushOptions) => {
|
||||
const command = action === 'pull'
|
||||
? () => syncPullCommand(options)
|
||||
: action === 'status'
|
||||
? () => syncStatusCommand(options as SyncCommonOptions)
|
||||
: action === 'diff'
|
||||
? () => syncDiffCommand(options as SyncCommonOptions)
|
||||
: action === 'push'
|
||||
? () => syncPushCommand(path, options)
|
||||
: () => Promise.reject(new CliError(
|
||||
`unknown sync action: ${action}`,
|
||||
EXIT.usage,
|
||||
{ next: 'use pull, status, diff, or push' }
|
||||
))
|
||||
return runCommand(command, Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('list', 'List local installs')
|
||||
.option('--agent <profile>', 'Filter by agent (repeatable)')
|
||||
|
|
|
|||
|
|
@ -111,21 +111,31 @@ function findEndOfCentralDirectory(view: DataView): number {
|
|||
* Returns the archive as a Blob.
|
||||
* Pure JS implementation using fflate — no system commands needed.
|
||||
*/
|
||||
export async function createZip(dirPath: string): Promise<Blob> {
|
||||
export interface CreateZipOptions {
|
||||
exclude?: (relativePath: string) => boolean
|
||||
}
|
||||
|
||||
export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise<Blob> {
|
||||
const entries: Record<string, Uint8Array> = {}
|
||||
await collectFiles(dirPath, dirPath, entries)
|
||||
await collectFiles(dirPath, dirPath, entries, options)
|
||||
const zipped = zipSync(entries, { level: 6 })
|
||||
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
|
||||
async function collectFiles(
|
||||
basePath: string,
|
||||
currentPath: string,
|
||||
entries: Record<string, Uint8Array>,
|
||||
options: CreateZipOptions
|
||||
): Promise<void> {
|
||||
const items = await readdir(currentPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = join(currentPath, item.name)
|
||||
const relPath = relative(basePath, fullPath)
|
||||
const relPath = relative(basePath, fullPath).split('\\').join('/')
|
||||
if (options.exclude?.(relPath)) continue
|
||||
if (item.isDirectory()) {
|
||||
entries[relPath + '/'] = new Uint8Array(0)
|
||||
await collectFiles(basePath, fullPath, entries)
|
||||
await collectFiles(basePath, fullPath, entries, options)
|
||||
} else if (item.isFile()) {
|
||||
entries[relPath] = new Uint8Array(await readFile(fullPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { EXIT } from '../shared/constants'
|
|||
import { extractZip } from '../platform/archive'
|
||||
import { readBoundedResponseBody } from '../platform/download'
|
||||
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
|
||||
import { snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import type { AgentCandidate } from '../agents/types'
|
||||
import type { ResolveResponse } from '../clients/skillhub-client'
|
||||
|
||||
export interface InstallOptions {
|
||||
registry: string
|
||||
|
|
@ -18,6 +20,7 @@ export interface InstallOptions {
|
|||
targets: AgentCandidate[]
|
||||
force: boolean
|
||||
home?: string | undefined
|
||||
resolved?: ResolveResponse | undefined
|
||||
}
|
||||
|
||||
async function preflightInstallTargets(
|
||||
|
|
@ -55,7 +58,7 @@ async function preflightInstallTargets(
|
|||
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
|
||||
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
|
||||
const client = new SkillHubClient(options.registry, options.token)
|
||||
const resolved = await client.resolve(options.namespace, options.slug, options.version)
|
||||
const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version)
|
||||
const response = await client.download(options.namespace, options.slug, resolved.version)
|
||||
const buffer = await readBoundedResponseBody(response)
|
||||
|
||||
|
|
@ -71,6 +74,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
await extractZip(buffer, tempDir)
|
||||
|
||||
const installedAt = new Date().toISOString()
|
||||
const snapshot = await snapshotSkillDirectory(tempDir)
|
||||
const metaDir = join(tempDir, '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
|
|
@ -78,6 +82,9 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
namespace: options.namespace,
|
||||
slug: options.slug,
|
||||
version: resolved.version,
|
||||
fingerprint: resolved.fingerprint,
|
||||
files: snapshot.files,
|
||||
source: 'skillhub',
|
||||
agent: target.agent,
|
||||
installedAt
|
||||
}, null, 2))
|
||||
|
|
@ -89,14 +96,30 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
})
|
||||
}
|
||||
|
||||
if (await pathExists(skillDir) && options.force) {
|
||||
await store.removeTargetsByInstallDir(skillDir)
|
||||
await rm(skillDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
|
||||
let backupCreated = false
|
||||
try {
|
||||
if (await pathExists(skillDir)) {
|
||||
await rename(skillDir, backupDir)
|
||||
backupCreated = true
|
||||
}
|
||||
await rename(tempDir, skillDir)
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.replaceTargetAtInstallDir(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
}, resolved.fingerprint)
|
||||
|
||||
if (backupCreated) await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
} catch (error) {
|
||||
if (movedIntoPlace) {
|
||||
await rm(skillDir, { recursive: true, force: true }).catch(() => {})
|
||||
movedIntoPlace = false
|
||||
}
|
||||
if (backupCreated) await rename(backupDir, skillDir).catch(() => {})
|
||||
if (!options.force && await pathExists(skillDir)) {
|
||||
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
|
||||
path: skillDir,
|
||||
|
|
@ -105,14 +128,6 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
}
|
||||
throw error
|
||||
}
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
})
|
||||
} finally {
|
||||
if (!movedIntoPlace) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
|
|
|
|||
54
cli/src/services/skill-fingerprint.ts
Normal file
54
cli/src/services/skill-fingerprint.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
export interface SkillSnapshot {
|
||||
fingerprint: string
|
||||
files: Record<string, string>
|
||||
}
|
||||
|
||||
export async function snapshotSkillDirectory(skillDir: string): Promise<SkillSnapshot> {
|
||||
const paths = await listSkillFiles(skillDir)
|
||||
const files: Record<string, string> = {}
|
||||
const aggregate = createHash('sha256')
|
||||
|
||||
for (const path of paths) {
|
||||
const content = await readFile(join(skillDir, path))
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
files[path] = fileHash
|
||||
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
|
||||
}
|
||||
|
||||
return { fingerprint: `sha256:${aggregate.digest('hex')}`, files }
|
||||
}
|
||||
|
||||
export function diffSkillFiles(
|
||||
baseline: Record<string, string> | undefined,
|
||||
current: Record<string, string>
|
||||
): string[] {
|
||||
if (!baseline) return []
|
||||
const paths = new Set([...Object.keys(baseline), ...Object.keys(current)])
|
||||
return [...paths]
|
||||
.filter(path => baseline[path] !== current[path])
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function listSkillFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
|
||||
async function walk(current: string): Promise<void> {
|
||||
const entries = await readdir(current, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.skillhub') continue
|
||||
const absolute = join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolute)
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative(root, absolute).split('\\').join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return files.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
353
cli/src/services/sync-service.ts
Normal file
353
cli/src/services/sync-service.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client'
|
||||
import { installSkill } from './install-service'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import { InventoryStore } from '../stores/inventory-store'
|
||||
import { SyncWorkspaceStore, type NamespaceSyncState } from '../stores/sync-workspace-store'
|
||||
import { createZip, isZipFile } from '../platform/archive'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed'
|
||||
|
||||
export interface SkillSyncMetadata {
|
||||
registry: string
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint: string
|
||||
files?: Record<string, string>
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface SyncStatusEntry {
|
||||
namespace: string
|
||||
slug: string
|
||||
status: SyncStatus
|
||||
localVersion?: string
|
||||
remoteVersion?: string
|
||||
changedFiles: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface PullResult {
|
||||
namespace: string
|
||||
rootDir: string
|
||||
entries: SyncStatusEntry[]
|
||||
actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }>
|
||||
failures: Array<{ slug: string; message: string }>
|
||||
}
|
||||
|
||||
export interface PushResultItem {
|
||||
path: string
|
||||
slug?: string
|
||||
version?: string
|
||||
status?: string
|
||||
action: 'validated' | 'uploaded' | 'submitted-review' | 'failed'
|
||||
errors?: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export async function listAllNamespaceSkills(
|
||||
client: SkillHubClient,
|
||||
namespace: string
|
||||
): Promise<NamespaceSyncItem[]> {
|
||||
const items: NamespaceSyncItem[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await client.listNamespaceSkills(namespace, cursor, 100)
|
||||
items.push(...page.items)
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
return items
|
||||
}
|
||||
|
||||
export async function inspectNamespaceWorkspace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
remoteItems?: NamespaceSyncItem[]
|
||||
}): Promise<{ entries: SyncStatusEntry[]; remoteItems: NamespaceSyncItem[] }> {
|
||||
const remoteItems = options.remoteItems ?? await listAllNamespaceSkills(options.client, options.namespace)
|
||||
const managed = await scanManagedSkills(options.rootDir, options.registry, options.namespace)
|
||||
const entries: SyncStatusEntry[] = []
|
||||
const remoteSlugs = new Set(remoteItems.map(item => item.slug))
|
||||
|
||||
for (const remote of remoteItems) {
|
||||
const skillDir = join(options.rootDir, remote.slug)
|
||||
const metadata = managed.get(remote.slug)
|
||||
if (!(await pathExists(skillDir))) {
|
||||
entries.push(baseEntry(remote, 'not-installed'))
|
||||
continue
|
||||
}
|
||||
if (!metadata) {
|
||||
entries.push({ ...baseEntry(remote, 'local-changed'), reason: 'directory is not managed by SkillHub' })
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (metadata.fingerprint !== remote.fingerprint) {
|
||||
entries.push({
|
||||
...baseEntry(remote, 'update-available'),
|
||||
localVersion: metadata.version
|
||||
})
|
||||
continue
|
||||
}
|
||||
entries.push({ ...baseEntry(remote, 'up-to-date'), localVersion: metadata.version })
|
||||
}
|
||||
|
||||
for (const [slug, metadata] of managed) {
|
||||
if (!remoteSlugs.has(slug)) {
|
||||
const snapshot = await snapshotSkillDirectory(join(options.rootDir, slug))
|
||||
const orphan: SyncStatusEntry = {
|
||||
namespace: options.namespace,
|
||||
slug,
|
||||
status: 'orphaned',
|
||||
localVersion: metadata.version,
|
||||
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
|
||||
}
|
||||
if (snapshot.fingerprint !== metadata.fingerprint) orphan.reason = 'local changes detected'
|
||||
entries.push(orphan)
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.slug.localeCompare(right.slug))
|
||||
return { entries, remoteItems }
|
||||
}
|
||||
|
||||
export async function pullNamespace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
check: boolean
|
||||
prune: boolean
|
||||
force: boolean
|
||||
}): Promise<PullResult> {
|
||||
const inspected = await inspectNamespaceWorkspace(options)
|
||||
const result: PullResult = {
|
||||
namespace: options.namespace,
|
||||
rootDir: options.rootDir,
|
||||
entries: inspected.entries,
|
||||
actions: [],
|
||||
failures: []
|
||||
}
|
||||
if (options.check) return result
|
||||
|
||||
const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item]))
|
||||
for (const entry of inspected.entries) {
|
||||
if (entry.status === 'up-to-date' || entry.status === 'orphaned') continue
|
||||
if (entry.status === 'local-changed' && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: entry.reason ?? 'local changes detected; pass --force to overwrite' })
|
||||
continue
|
||||
}
|
||||
const remote = remoteBySlug.get(entry.slug)
|
||||
if (!remote) continue
|
||||
try {
|
||||
await installSkill({
|
||||
registry: options.registry,
|
||||
token: options.token,
|
||||
namespace: options.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
resolved: {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
versionId: remote.versionId,
|
||||
fingerprint: remote.fingerprint,
|
||||
downloadUrl: remote.downloadUrl
|
||||
},
|
||||
targets: [{ agent: 'workspace', rootDir: options.rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: entry.status !== 'not-installed' || options.force
|
||||
})
|
||||
result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' })
|
||||
} catch (error) {
|
||||
result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.prune) {
|
||||
for (const entry of inspected.entries.filter(item => item.status === 'orphaned')) {
|
||||
if (entry.reason && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: 'orphan has local changes; pass --force to prune' })
|
||||
continue
|
||||
}
|
||||
const installDir = join(options.rootDir, entry.slug)
|
||||
const backupDir = `${installDir}.skillhub-prune-${process.pid}-${Date.now()}`
|
||||
try {
|
||||
await rename(installDir, backupDir)
|
||||
try {
|
||||
await new InventoryStore().removeTargetsByInstallDir(installDir)
|
||||
} catch (error) {
|
||||
await rename(backupDir, installDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
result.actions.push({ slug: entry.slug, action: 'pruned' })
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
slug: entry.slug,
|
||||
message: error instanceof Error ? error.message : 'prune failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.failures.length === 0) {
|
||||
const state: NamespaceSyncState = {
|
||||
registry: options.registry,
|
||||
namespace: options.namespace,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
skills: Object.fromEntries(inspected.remoteItems.map(item => [item.slug, {
|
||||
version: item.version,
|
||||
fingerprint: item.fingerprint
|
||||
}]))
|
||||
}
|
||||
await new SyncWorkspaceStore(options.rootDir).write(state)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function pushSkills(options: {
|
||||
client: SkillHubClient
|
||||
namespace: string
|
||||
paths: string[]
|
||||
visibility: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE'
|
||||
dryRun: boolean
|
||||
submitReview: boolean
|
||||
}): Promise<PushResultItem[]> {
|
||||
const results: PushResultItem[] = []
|
||||
for (const path of options.paths) {
|
||||
try {
|
||||
const archive = await prepareArchive(path)
|
||||
const validation = await options.client.validatePublish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
if (!validation.valid) {
|
||||
const failed: PushResultItem = {
|
||||
path,
|
||||
action: 'failed',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) failed.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) failed.version = validation.resolvedVersion
|
||||
results.push(failed)
|
||||
continue
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const validated: PushResultItem = {
|
||||
path,
|
||||
action: 'validated',
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) validated.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) validated.version = validation.resolvedVersion
|
||||
results.push(validated)
|
||||
continue
|
||||
}
|
||||
|
||||
const published = await options.client.publish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
let action: PushResultItem['action'] = 'uploaded'
|
||||
let status = published.status
|
||||
if (options.submitReview && published.status === 'PENDING_REVIEW') {
|
||||
action = 'submitted-review'
|
||||
} else if (options.submitReview && published.status === 'UPLOADED') {
|
||||
if (options.visibility === 'PRIVATE') {
|
||||
throw new Error('--submit-review requires public or namespace-only visibility')
|
||||
}
|
||||
const review = await options.client.submitReview(
|
||||
options.namespace,
|
||||
published.slug,
|
||||
published.version,
|
||||
options.visibility
|
||||
)
|
||||
action = 'submitted-review'
|
||||
status = review.status
|
||||
}
|
||||
results.push({ path, slug: published.slug, version: published.version, status, action })
|
||||
} catch (error) {
|
||||
results.push({ path, action: 'failed', errors: [error instanceof Error ? error.message : 'push failed'] })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function discoverSkillDirectories(rootDir: string): Promise<string[]> {
|
||||
if (!(await pathExists(rootDir))) return []
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
const paths: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const path = join(rootDir, entry.name)
|
||||
if (await pathExists(join(path, 'SKILL.md'))) paths.push(path)
|
||||
}
|
||||
return paths.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function prepareArchive(path: string): Promise<{ blob: Blob; fileName: string }> {
|
||||
const pathStat = await stat(path)
|
||||
if (pathStat.isDirectory()) {
|
||||
return {
|
||||
blob: await createZip(path, { exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') }),
|
||||
fileName: `${basename(path)}.zip`
|
||||
}
|
||||
}
|
||||
if (pathStat.isFile() && await isZipFile(path)) {
|
||||
return { blob: new Blob([await readFile(path)], { type: 'application/zip' }), fileName: basename(path) }
|
||||
}
|
||||
throw new Error(`path must be a skill directory or zip archive: ${path}`)
|
||||
}
|
||||
|
||||
async function scanManagedSkills(
|
||||
rootDir: string,
|
||||
registry: string,
|
||||
namespace: string
|
||||
): Promise<Map<string, SkillSyncMetadata>> {
|
||||
const managed = new Map<string, SkillSyncMetadata>()
|
||||
if (!(await pathExists(rootDir))) return managed
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const metadataPath = join(rootDir, entry.name, '.skillhub', 'metadata.json')
|
||||
if (!(await pathExists(metadataPath))) continue
|
||||
try {
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SkillSyncMetadata
|
||||
if (metadata.source === 'skillhub'
|
||||
&& normalizeRegistry(metadata.registry) === normalizeRegistry(registry)
|
||||
&& metadata.namespace === namespace
|
||||
&& metadata.slug === entry.name) {
|
||||
managed.set(entry.name, metadata)
|
||||
}
|
||||
} catch {
|
||||
// Corrupt metadata is treated as an unmanaged local directory.
|
||||
}
|
||||
}
|
||||
return managed
|
||||
}
|
||||
|
||||
function baseEntry(remote: NamespaceSyncItem, status: SyncStatus): SyncStatusEntry {
|
||||
return {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
status,
|
||||
remoteVersion: remote.version,
|
||||
changedFiles: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRegistry(registry: string): string {
|
||||
return registry.replace(/\/+$/, '')
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export interface InventoryItem {
|
|||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint?: string
|
||||
targets: InventoryTarget[]
|
||||
}
|
||||
|
||||
|
|
@ -117,17 +118,19 @@ export class InventoryStore {
|
|||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
let item = inventory.items.find(
|
||||
const existing = inventory.items.find(
|
||||
i => i.registry === registry && i.namespace === namespace && i.slug === slug
|
||||
)
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] }
|
||||
if (!existing) {
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
|
||||
if (existingIdx >= 0) {
|
||||
item.targets[existingIdx] = target
|
||||
|
|
@ -165,4 +168,30 @@ export class InventoryStore {
|
|||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async replaceTargetAtInstallDir(
|
||||
registry: string,
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
for (const item of inventory.items) {
|
||||
item.targets = item.targets.filter(existing => existing.installDir !== target.installDir)
|
||||
}
|
||||
inventory.items = inventory.items.filter(item => item.targets.length > 0)
|
||||
|
||||
let item = inventory.items.find(candidate =>
|
||||
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
item.targets.push(target)
|
||||
await this.writeAtomic(inventory)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
cli/src/stores/sync-workspace-store.ts
Normal file
39
cli/src/stores/sync-workspace-store.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export interface NamespaceSyncStateSkill {
|
||||
version: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncState {
|
||||
registry: string
|
||||
namespace: string
|
||||
lastSyncAt: string
|
||||
skills: Record<string, NamespaceSyncStateSkill>
|
||||
}
|
||||
|
||||
export class SyncWorkspaceStore {
|
||||
readonly path: string
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.path = join(rootDir, '.skillhub', 'namespace-sync.json')
|
||||
}
|
||||
|
||||
async read(): Promise<NamespaceSyncState | null> {
|
||||
if (!(await pathExists(this.path))) return null
|
||||
return JSON.parse(await readFile(this.path, 'utf8')) as NamespaceSyncState
|
||||
}
|
||||
|
||||
async write(state: NamespaceSyncState): Promise<void> {
|
||||
await mkdir(dirname(this.path), { recursive: true })
|
||||
const tempPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
await writeFile(tempPath, JSON.stringify(state, null, 2))
|
||||
await rename(tempPath, this.path)
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,12 +102,14 @@ export interface CapturedPublish {
|
|||
fileName: string
|
||||
/** Visibility string from the multipart form field. */
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
export interface CapturedValidate {
|
||||
namespace: string
|
||||
fileName: string
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
|
||||
|
|
@ -125,6 +127,13 @@ export interface CapturedDelete {
|
|||
token: string | null
|
||||
}
|
||||
|
||||
export interface CapturedReview {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
targetVisibility: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -137,6 +146,7 @@ interface FakeRegistryOptions {
|
|||
skills?: FakeSkill[]
|
||||
/** Response to return for publish/validate (dry-run) requests. */
|
||||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
publishStatus?: string
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -150,6 +160,8 @@ interface FakeRegistryOptions {
|
|||
deleteRemote?: FailureMode
|
||||
publish?: FailureMode
|
||||
validate?: FailureMode
|
||||
namespaceSync?: FailureMode
|
||||
submitReview?: FailureMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +202,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
resolve: CapturedResolve | null
|
||||
delete: CapturedDelete | null
|
||||
validate: CapturedValidate | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null }
|
||||
review: CapturedReview | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
// TCP-level failure. Start a connection-dropping server and return its URL
|
||||
|
|
@ -263,6 +276,31 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
const namespaceSyncMatch = path.match(/^\/api\/cli\/v1\/namespaces\/([^/]+)\/skills$/)
|
||||
if (namespaceSyncMatch && req.method === 'GET') {
|
||||
if (options.failures?.namespaceSync) return failureResponse(options.failures.namespaceSync)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
const namespace = namespaceSyncMatch[1]!
|
||||
const skills = (options.skills ?? []).filter(skill => skill.namespace === namespace)
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
items: skills.map((skill, index) => ({
|
||||
namespace,
|
||||
slug: skill.slug,
|
||||
version: skill.version ?? '1.0.0',
|
||||
versionId: skill.versionId ?? index + 1,
|
||||
fingerprint: skill.fingerprint ?? 'deadbeef',
|
||||
updatedAt: '2026-08-18T00:00:00Z',
|
||||
visibility: 'NAMESPACE_ONLY',
|
||||
downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0')
|
||||
})),
|
||||
nextCursor: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Route: /api/cli/v1/skills/:namespace/:slug/...
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
@ -377,7 +415,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
if (fileField instanceof File) {
|
||||
fileName = fileField.name || fileName
|
||||
}
|
||||
state.validate = { namespace, fileName, visibility }
|
||||
state.validate = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
|
||||
const dryRunData = options.dryRunResponse ?? {
|
||||
valid: true,
|
||||
|
|
@ -410,7 +453,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
}
|
||||
|
||||
// Record for test assertions.
|
||||
state.publish = { namespace, fileName, visibility }
|
||||
state.publish = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
code: 0,
|
||||
|
|
@ -418,12 +466,30 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
namespace,
|
||||
slug: fileName.replace(/\.zip$/, ''),
|
||||
version: '1.0.0',
|
||||
visibility
|
||||
visibility,
|
||||
status: options.publishStatus ?? 'PENDING_REVIEW'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const submitReviewMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/([^/]+)\/submit-review$/)
|
||||
if (submitReviewMatch && req.method === 'POST') {
|
||||
if (options.failures?.submitReview) return failureResponse(options.failures.submitReview)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
return req.json().then(body => {
|
||||
const request = body as { version: string; targetVisibility: string }
|
||||
const namespace = submitReviewMatch[1]!
|
||||
const slug = submitReviewMatch[2]!
|
||||
state.review = { namespace, slug, version: request.version, targetVisibility: request.targetVisibility }
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { skillId: 1, versionId: 1, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Fallthrough
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
|
|||
187
cli/test/integration/sync-command.test.ts
Normal file
187
cli/test/integration/sync-command.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
|
||||
const content = strToU8(body)
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
const fingerprint = `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`
|
||||
return { zipBytes: zipSync({ 'SKILL.md': content }), fingerprint }
|
||||
}
|
||||
|
||||
describe('sync command', () => {
|
||||
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const first = makeSkill('---\nname: first\ndescription: First\nversion: 1.0.0\n---\n')
|
||||
const second = makeSkill('---\nname: second\ndescription: Second\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [
|
||||
{ namespace: 'team-a', slug: 'first', ...first },
|
||||
{ namespace: 'team-a', slug: 'second', ...second }
|
||||
]
|
||||
})
|
||||
|
||||
try {
|
||||
const pulled = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pulled.exitCode).toBe(0)
|
||||
expect(JSON.parse(pulled.stdout).actions).toHaveLength(2)
|
||||
const metadata = JSON.parse(await readFile(join(skillsDir, 'first', '.skillhub', 'metadata.json'), 'utf8'))
|
||||
expect(metadata).toMatchObject({
|
||||
source: 'skillhub', namespace: 'team-a', slug: 'first', fingerprint: first.fingerprint
|
||||
})
|
||||
expect(await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')).toContain('team-a')
|
||||
|
||||
const secondPull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(secondPull.exitCode).toBe(0)
|
||||
expect(JSON.parse(secondPull.stdout).actions).toHaveLength(0)
|
||||
expect(JSON.parse(secondPull.stdout).entries.every((item: { status: string }) => item.status === 'up-to-date')).toBe(true)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('status detects local changes and pull does not overwrite without force', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
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 change\n')
|
||||
|
||||
const status = await runCli([
|
||||
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(JSON.parse(status.stdout).items[0].status).toBe('local-changed')
|
||||
|
||||
const pull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pull.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# local change\n')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('prune removes only unchanged managed orphan skills', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const skills: FakeSkill[] = [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
const registry = await startFakeRegistry({ token: 'token', skills })
|
||||
|
||||
try {
|
||||
await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
skills.splice(0, skills.length)
|
||||
|
||||
const pruned = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--prune',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pruned.exitCode).toBe(0)
|
||||
expect(JSON.parse(pruned.stdout).actions).toContainEqual({ slug: 'demo', action: 'pruned' })
|
||||
expect(await Bun.file(join(skillsDir, 'demo')).exists()).toBe(false)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('push all validates packages and submits an uploaded version for review', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const skillDir = join(skillsDir, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
|
||||
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
|
||||
|
||||
try {
|
||||
const pushed = await runCli([
|
||||
'sync', 'push', '--all', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--submit-review', '--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pushed.exitCode).toBe(0)
|
||||
expect(JSON.parse(pushed.stdout).items[0].action).toBe('submitted-review')
|
||||
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
|
||||
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.review).toMatchObject({
|
||||
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
|
||||
})
|
||||
} finally {
|
||||
registry.stop()
|
||||
await rm(skillsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('push dry-run uses strict validation without uploading', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({ token: 'token' })
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'push', skillDir, '--namespace', 'team-a', '--dry-run',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout).items[0].action).toBe('validated')
|
||||
expect(registry.received.validate?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.publish).toBeNull()
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('pull refuses to replace an unmanaged conflicting directory', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
await mkdir(join(skillsDir, 'demo'), { recursive: true })
|
||||
await writeFile(join(skillsDir, 'demo', 'local.txt'), 'keep')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token', skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'local.txt'), 'utf8')).toBe('keep')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -15,7 +15,8 @@ describe('SkillHubClient', () => {
|
|||
namespace: 'team',
|
||||
slug: 'custom-skill',
|
||||
version: '1.0.0',
|
||||
visibility: 'PRIVATE'
|
||||
visibility: 'PRIVATE',
|
||||
status: 'UPLOADED'
|
||||
}
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
|
@ -188,6 +189,34 @@ describe('SkillHubClient', () => {
|
|||
expect(capturedUrl).toContain('?version=2.0.0')
|
||||
})
|
||||
|
||||
test('listNamespaceSkills forwards cursor and limit', async () => {
|
||||
let capturedUrl = ''
|
||||
const fetchImpl = (async (input: URL | RequestInfo) => {
|
||||
capturedUrl = String(input)
|
||||
return Response.json({ data: { items: [], nextCursor: null } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await client.listNamespaceSkills('team a', 'cursor-value', 50)
|
||||
|
||||
expect(capturedUrl).toContain('/api/cli/v1/namespaces/team%20a/skills')
|
||||
expect(capturedUrl).toContain('cursor=cursor-value')
|
||||
expect(capturedUrl).toContain('limit=50')
|
||||
})
|
||||
|
||||
test('submitReview posts the lifecycle request with bearer auth', async () => {
|
||||
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(String(input)).toContain('/api/v1/skills/team-a/demo/submit-review')
|
||||
expect(init?.headers).toMatchObject({ Authorization: 'Bearer token', 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY' })
|
||||
return Response.json({ data: { skillId: 1, versionId: 2, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.submitReview('team-a', 'demo', '1.0.0', 'NAMESPACE_ONLY'))
|
||||
.resolves.toMatchObject({ status: 'PENDING_REVIEW' })
|
||||
})
|
||||
|
||||
// --- handleJsonResponse() non-2xx classification ---
|
||||
|
||||
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {
|
||||
|
|
|
|||
|
|
@ -228,6 +228,27 @@ describe('installSkill', () => {
|
|||
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
|
||||
})
|
||||
|
||||
test('force restores the old installation when inventory persistence fails', async () => {
|
||||
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
|
||||
const skillDir = join(rootDir, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
|
||||
const invalidHome = join(rootDir, 'home-is-a-file')
|
||||
await writeFile(invalidHome, 'not a directory')
|
||||
|
||||
await expect(installSkill({
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: true,
|
||||
home: invalidHome
|
||||
})).rejects.toThrow()
|
||||
|
||||
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
|
||||
})
|
||||
|
||||
test('rejects downloads whose content-length exceeds the package limit', async () => {
|
||||
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
|
||||
status: 200,
|
||||
|
|
|
|||
23
cli/test/unit/services/skill-fingerprint.test.ts
Normal file
23
cli/test/unit/services/skill-fingerprint.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createTempHome } from '../../helpers/temp-env'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from '../../../src/services/skill-fingerprint'
|
||||
|
||||
describe('skill fingerprint', () => {
|
||||
test('ignores SkillHub metadata and reports changed files', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# one\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":true}')
|
||||
|
||||
const baseline = await snapshotSkillDirectory(skillDir)
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":false}')
|
||||
expect((await snapshotSkillDirectory(skillDir)).fingerprint).toBe(baseline.fingerprint)
|
||||
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# two\n')
|
||||
const current = await snapshotSkillDirectory(skillDir)
|
||||
expect(diffSkillFiles(baseline.files, current.files)).toEqual(['SKILL.md'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue