From 84feb389315a91196a82ed13dd3acc7a41ceaea8 Mon Sep 17 00:00:00 2001 From: mrLi <50289372+15258725278@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:24:11 +0800 Subject: [PATCH] feat(cli): add namespace workspace sync Signed-off-by: mrLi <50289372+15258725278@users.noreply.github.com> --- cli/README.md | 37 ++ cli/package.json | 2 +- cli/src/clients/skillhub-client.ts | 70 +++- cli/src/commands/help.ts | 9 + cli/src/commands/sync.ts | 179 +++++++++ cli/src/generated/pkg-info.ts | 2 +- cli/src/index.ts | 33 ++ cli/src/platform/archive.ts | 20 +- cli/src/services/install-service.ts | 43 ++- cli/src/services/skill-fingerprint.ts | 54 +++ cli/src/services/sync-service.ts | 353 ++++++++++++++++++ cli/src/stores/inventory-store.ts | 37 +- cli/src/stores/sync-workspace-store.ts | 39 ++ cli/test/helpers/fake-registry.ts | 74 +++- cli/test/integration/sync-command.test.ts | 187 ++++++++++ cli/test/unit/clients/skillhub-client.test.ts | 31 +- .../unit/services/install-service.test.ts | 21 ++ .../unit/services/skill-fingerprint.test.ts | 23 ++ 18 files changed, 1182 insertions(+), 32 deletions(-) create mode 100644 cli/src/commands/sync.ts create mode 100644 cli/src/services/skill-fingerprint.ts create mode 100644 cli/src/services/sync-service.ts create mode 100644 cli/src/stores/sync-workspace-store.ts create mode 100644 cli/test/integration/sync-command.test.ts create mode 100644 cli/test/unit/services/skill-fingerprint.test.ts diff --git a/cli/README.md b/cli/README.md index 37016be4..11014547 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 `/.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 diff --git a/cli/package.json b/cli/package.json index e16b3a20..8e4105a3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 1842a178..ab787cec 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -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 { + 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 { 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 { + async publish( + namespace: string, + file: Blob, + visibility: string, + fileName = 'skill.zip', + rejectExistingVersion = false + ): Promise { 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(response) } - async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise { + async validatePublish( + namespace: string, + file: Blob, + visibility: string, + fileName = 'skill.zip', + rejectExistingVersion = false + ): Promise { 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(response) } + async submitReview( + namespace: string, + slug: string, + version: string, + targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY' + ): Promise { + 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(response) + } + private async getJson(path: string): Promise { let response: Response try { diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 00a197c3..37857834 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -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 [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 ] [--dir ] [--registry ] [--json]', diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts new file mode 100644 index 00000000..3c998ea5 --- /dev/null +++ b/cli/src/commands/sync.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index 9445834b..b82a1a23 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -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" diff --git a/cli/src/index.ts b/cli/src/index.ts index 12a2408e..d743d46a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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 [path]', 'Synchronize and maintain a namespace workspace') + .option('--namespace ', 'Namespace', { default: 'global' }) + .option('--dir ', '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 ', '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 ', 'Registry URL') + .option('--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 ', 'Filter by agent (repeatable)') diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index f549185d..e10f28b0 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -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 { +export interface CreateZipOptions { + exclude?: (relativePath: string) => boolean +} + +export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise { const entries: Record = {} - 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): Promise { +async function collectFiles( + basePath: string, + currentPath: string, + entries: Record, + options: CreateZipOptions +): Promise { 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)) } diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index bba71345..36ba1f4b 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -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(() => {}) diff --git a/cli/src/services/skill-fingerprint.ts b/cli/src/services/skill-fingerprint.ts new file mode 100644 index 00000000..2460fec3 --- /dev/null +++ b/cli/src/services/skill-fingerprint.ts @@ -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 +} + +export async function snapshotSkillDirectory(skillDir: string): Promise { + const paths = await listSkillFiles(skillDir) + const files: Record = {} + 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 | undefined, + current: Record +): 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 { + const files: string[] = [] + + async function walk(current: string): Promise { + 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)) +} diff --git a/cli/src/services/sync-service.ts b/cli/src/services/sync-service.ts new file mode 100644 index 00000000..ca7377bf --- /dev/null +++ b/cli/src/services/sync-service.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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> { + const managed = new Map() + 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(/\/+$/, '') +} diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index bb3af84a..3b004683 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -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 { 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 { + 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) + } } diff --git a/cli/src/stores/sync-workspace-store.ts b/cli/src/stores/sync-workspace-store.ts new file mode 100644 index 00000000..2d82039f --- /dev/null +++ b/cli/src/stores/sync-workspace-store.ts @@ -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 +} + +export class SyncWorkspaceStore { + readonly path: string + + constructor(rootDir: string) { + this.path = join(rootDir, '.skillhub', 'namespace-sync.json') + } + + async read(): Promise { + if (!(await pathExists(this.path))) return null + return JSON.parse(await readFile(this.path, 'utf8')) as NamespaceSyncState + } + + async write(state: NamespaceSyncState): Promise { + 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(() => {}) + } + } +} diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts index 51b02b94..6a3b7bce 100644 --- a/cli/test/helpers/fake-registry.ts +++ b/cli/test/helpers/fake-registry.ts @@ -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 // ------------------------------------------------------------------ // diff --git a/cli/test/integration/sync-command.test.ts b/cli/test/integration/sync-command.test.ts new file mode 100644 index 00000000..a6e97b39 --- /dev/null +++ b/cli/test/integration/sync-command.test.ts @@ -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() + } + }) +}) diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index 187350c1..6d0249d2 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -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 () => { diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 5d3b0ec5..95525d5f 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -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, diff --git a/cli/test/unit/services/skill-fingerprint.test.ts b/cli/test/unit/services/skill-fingerprint.test.ts new file mode 100644 index 00000000..dd995533 --- /dev/null +++ b/cli/test/unit/services/skill-fingerprint.test.ts @@ -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']) + }) +})