Merge remote-tracking branch 'origin/main' into codex/maintain-pr788-20260904

# Conflicts:
#	web/src/pages/skill-detail.tsx
This commit is contained in:
XiaoSeS 2026-09-04 09:48:00 +08:00
commit d0d43bbf45
244 changed files with 11962 additions and 2038 deletions

View file

@ -515,8 +515,9 @@ Because SkillHub speaks the same `SKILL.md` format, skills from `anthropics/skil
git clone https://github.com/anthropics/skills
# ...and publish it into your private SkillHub registry
export CLAWHUB_REGISTRY=https://skillhub.your-company.com
npx clawhub publish ./skills/<category>/<skill-name>
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./skills/<category>/<skill-name>
```
> ⚖️ **Licensing**: honor each skill's own license when republishing. Most skills in
@ -546,16 +547,18 @@ npx clawhub search email
npx clawhub install my-skill
npx clawhub install my-namespace--my-skill
# Publish to global namespace
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
# Publish to a team namespace such as my-space
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
# Publishing uses the first-party SkillHub CLI
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space
```
`my-space--my-skill` is the canonical compat slug. SkillHub parses it as
namespace `my-space` plus skill slug `my-skill`.
ClawHub compatibility covers search, inspection, and installation. Its publish
protocol is not compatible with SkillHub; use the first-party CLI shown above.
> 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill`
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**

View file

@ -401,8 +401,9 @@ Agent Skill 目录——都可以直接发布到你的注册中心:
git clone https://github.com/anthropics/skills
# ……并将其发布到你的私有 SkillHub 注册中心
export CLAWHUB_REGISTRY=https://skillhub.your-company.com
npx clawhub publish ./skills/<分类>/<技能名>
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./skills/<分类>/<技能名>
```
> ⚖️ **许可提示**:转发布时请遵守每个技能各自的许可证。`anthropics/skills` 中大多数技能
@ -431,16 +432,18 @@ npx clawhub search email
npx clawhub install my-skill
npx clawhub install my-namespace--my-skill
# 发布到 global 空间
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
# 发布到如 my-space 这样的团队空间
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
# 发布请使用第一方 SkillHub CLI
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space
```
其中 `my-space--my-skill` 是兼容层使用的 canonical slugSkillHub 会将其解析为
namespace `my-space` 和 skill slug `my-skill`
ClawHub 兼容范围包含搜索、查看和安装;其发布协议与 SkillHub 不兼容。
发布请使用上面的第一方 CLI。
> 💡 **提示**:上述命令不仅适用于 OpenClaw通过指定安装目录`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill`
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**

View file

@ -4,8 +4,20 @@ All notable CLI behavior changes are documented in this file.
## Unreleased
### Added
- Add `skillhub upgrade <coordinate...>` for bounded, explicit upgrades of already-installed Skills,
including side-effect-free `--check`, structured `--json`, local-change protection, and target
filters.
### Fixed
- Prevent `--force` from overwriting an unmanaged or different-source Skill at the same target path.
Source ownership is the full `registry + namespace + slug` identity.
- Reject registry downgrades and partial-target updates that the shared inventory version cannot
represent safely.
- Exclude installer-owned `.skillhub/` state when publishing a local Skill directory.
- Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug`
coordinates against their declared namespace instead of silently falling
back to `global`.

View file

@ -171,7 +171,7 @@ skillhub install pdf-parser --agent codex --agent claude-code
# Install to custom directory
skillhub install pdf-parser --dir ~/.claude/skills
# Force overwrite existing installation
# Reinstall a SkillHub-managed installation from the same source
skillhub install pdf-parser --force
```
@ -228,17 +228,52 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
```json
{
"schemaVersion": 1,
"registry": "https://skill.xfyun.cn",
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"versionId": 123,
"fingerprint": "sha256:...",
"files": { "SKILL.md": "sha256..." },
"source": "skillhub",
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
The CLI creates `.skillhub/metadata.json` after extracting a downloaded package. It is not part of
the published ZIP and is excluded when a managed directory is published again.
## ⬆️ Upgrade Installed Skills
`upgrade` only operates on explicitly selected, SkillHub-managed local installations. It never
installs a missing Skill and has no implicit upgrade-all mode.
```bash
# Preview without changing files
skillhub upgrade @global/skillhub-registry --check
# Upgrade one or a bounded list of installed Skills
skillhub upgrade @global/skillhub-registry
skillhub upgrade @team/code-review @team/java-guide
# Machine-readable plan
skillhub upgrade @team/code-review --check --json
```
The source identity is `registry + namespace + slug`. `--force` may replace local changes only when
that full identity matches the installation metadata; it never overwrites an unmanaged directory or
a Skill installed from another source.
All targets in one inventory entry are upgraded together. A filter that selects only part of that
entry is rejected because the current inventory format stores one shared version for all targets.
The command also keeps the local files when the registry resolves to an older version.
If a multi-Skill run fails after an earlier upgrade commits, execution stops and reports each item
as `upgraded`, `failed`, or `not-attempted`; a committed upgrade is never rolled back implicitly.
New installations store absolute target paths. An older inventory entry with relative target paths
must be reinstalled before upgrade because its original working directory cannot be recovered safely.
## 🔄 Namespace Workspaces
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
@ -267,6 +302,12 @@ 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.
Sync compares both the published version and package fingerprint. An exact match is `up-to-date`,
while a newer version is `update-available` even when its content is unchanged. An older remote
version, an unorderable version pair, or changed remote content without a version bump is `blocked`.
`--force` cannot bypass these release-safety checks; verify the release and use an explicit
`skillhub install` when replacement is intentional.
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`.
@ -402,6 +443,7 @@ Update mechanism:
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <coordinate> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub upgrade <coordinate...> [--namespace <slug>] [--agent <profile>] [--dir <path>] [--registry <url>] [--check] [--force] [--json]` | Upgrade explicitly selected installed skills |
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <coordinate> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |
| `skillhub doctor [--json]` | Scan project directory and rebuild local inventory |
@ -447,13 +489,16 @@ skillhub search test --registry https://skillhub.example.com
```bash
# Use --force to overwrite
skillhub install pdf-parser --force
skillhub install pdf-parser --force # same SkillHub source only
# Or remove first then install
skillhub remove pdf-parser
skillhub install pdf-parser
```
`--force` does not bypass source ownership. Move or explicitly remove an unmanaged or different-source
directory before installing another Skill with the same visible slug.
### Corrupted Inventory
```bash

View file

@ -8,12 +8,14 @@
"cac": "^6.7.14",
"fflate": "^0.8.2",
"prompts": "^2.4.2",
"proper-lockfile": "4.1.2",
"semver": "^7.6.3",
"zod": "^3.24.1",
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/prompts": "^2.4.9",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
@ -49,6 +51,10 @@
"@types/prompts": ["@types/prompts@2.4.9", "https://registry.npmmirror.com/@types/prompts/-/prompts-2.4.9.tgz", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "https://registry.npmmirror.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/retry": ["@types/retry@0.12.5", "https://registry.npmmirror.com/@types/retry/-/retry-0.12.5.tgz", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
"@types/semver": ["@types/semver@7.7.1", "https://registry.npmmirror.com/@types/semver/-/semver-7.7.1.tgz", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="],
@ -163,6 +169,8 @@
"globby": ["globby@11.1.0", "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphemer": ["graphemer@1.4.0", "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@ -239,12 +247,16 @@
"prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@3.0.2", "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
@ -257,6 +269,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slash": ["slash@3.0.0", "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],

View file

@ -48,12 +48,14 @@
"cac": "^6.7.14",
"fflate": "^0.8.2",
"prompts": "^2.4.2",
"proper-lockfile": "4.1.2",
"semver": "^7.6.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/prompts": "^2.4.9",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",

View file

@ -43,6 +43,15 @@ export const commands = {
'skillhub install pdf-parser --scope project --agent codex'
]
},
upgrade: {
summary: 'Upgrade explicitly selected installed skills',
usage: 'skillhub upgrade <coordinate...> [--namespace <slug>] [--agent <profile>] [--dir <path>] [--registry <url>] [--check] [--force] [--json]',
examples: [
'skillhub upgrade @global/skillhub-registry',
'skillhub upgrade @team/code-review @team/java-guide --check --json',
'skillhub upgrade code-review --namespace team --agent codex'
]
},
sync: {
summary: 'Synchronize and maintain namespace workspaces',
usage: 'skillhub sync <pull|status|diff|push> [options]',

View file

@ -115,7 +115,16 @@ export async function installCommand(
})
if (options.json) {
return JSON.stringify({ ok: true, namespace, slug, installed: result.installed })
return JSON.stringify({
ok: true,
namespace,
slug,
installed: result.installed,
...(result.warnings?.length ? { warnings: result.warnings } : {})
})
}
return result.installed.map(i => `Installed ${namespace}/${slug} -> ${i.dir} (${i.agent})`).join('\n')
return [
...result.installed.map(i => `Installed ${namespace}/${slug} -> ${i.dir} (${i.agent})`),
...(result.warnings ?? []).map(warning => `Warning: ${warning}`)
].join('\n')
}

View file

@ -49,7 +49,9 @@ export async function publishCommand(path: string, options: PublishCommandOption
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
}
} else if (pathStat.isDirectory()) {
archiveBlob = await createZip(path)
archiveBlob = await createZip(path, {
exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/')
})
archiveName = `${basename(path)}.zip`
} else {
throw new CliError(`path must be a file or directory: ${path}`, EXIT.filesystem, { path })

View file

@ -47,10 +47,15 @@ export async function syncPullCommand(options: SyncPullOptions): Promise<string>
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
})
const blocked = result.entries.filter(entry => entry.status === 'blocked')
throw new CliError(
blocked.length > 0 ? 'namespace sync blocked by remote version safety checks' : 'namespace sync completed with failures',
blocked.length > 0 ? EXIT.validation : EXIT.generic,
{
namespace: context.namespace,
failures: result.failures
}
)
}
return output
}
@ -135,7 +140,7 @@ async function resolveSyncContext(options: SyncCommonOptions): Promise<{
return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir }
}
function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
export function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
if (json) {
return JSON.stringify({ ok: result.failures.length === 0, check, ...result })
}
@ -145,6 +150,7 @@ function renderPullResult(result: PullResult, json: boolean, check: boolean): st
...result.entries
.filter(entry => !result.actions.some(action => action.slug === entry.slug))
.map(entry => `${entry.status.padEnd(16)} ${entry.slug}`),
...result.warnings.map(item => `warning ${item.slug}: ${item.message}`),
...result.failures.map(item => `failed ${item.slug}: ${item.message}`)
]
return lines.join('\n')

136
cli/src/commands/upgrade.ts Normal file
View file

@ -0,0 +1,136 @@
import { CredentialsStore } from '../stores/credentials-store'
import { resolveToken } from '../services/registry-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import {
executeSkillUpgradePlan,
planSkillUpgrades,
type UpgradeExecutionResult,
type UpgradePlan
} from '../services/upgrade-service'
export interface UpgradeCommandOptions {
namespace?: string | undefined
agent?: string[] | undefined
dir?: string | undefined
registry?: string | undefined
token?: string | undefined
check?: boolean | undefined
force?: boolean | undefined
json?: boolean | undefined
}
export async function upgradeCommand(coordinates: string[], options: UpgradeCommandOptions): Promise<string> {
const credentials = new CredentialsStore()
const tokenForRegistry = async (registry: string): Promise<string | undefined> =>
resolveToken(options, process.env, await credentials.getToken(registry))
const plan = await planSkillUpgrades({
coordinates,
namespace: options.namespace,
registry: options.registry,
agents: options.agent,
dir: options.dir,
force: Boolean(options.force),
tokenForRegistry
})
if (plan.blocked > 0) {
const output = renderUpgradePlan(plan, {
check: Boolean(options.check),
executed: false
}, Boolean(options.json))
process.stdout.write(`${output}\n`)
throw new CliError('upgrade plan contains blocked skills', EXIT.validation, {
blocked: plan.items.filter(item => item.action === 'blocked').map(item => ({
coordinate: item.coordinate,
reason: item.reason
}))
})
}
if (options.check) return renderUpgradePlan(plan, { check: true, executed: false }, Boolean(options.json))
const result = await executeSkillUpgradePlan(plan, { tokenForRegistry })
const output = renderUpgradeResult(plan, result, Boolean(options.json))
if (result.failed > 0) {
process.stdout.write(`${output}\n`)
const firstFailure = result.items.find(item => item.action === 'failed')
throw new CliError('one or more skills failed to upgrade', firstFailure?.exitCode ?? EXIT.generic, {
failed: result.items.filter(item => item.action === 'failed')
})
}
return output
}
function renderUpgradePlan(
plan: UpgradePlan,
state: { check: boolean; executed: boolean },
json: boolean
): string {
if (json) {
return JSON.stringify({
ok: plan.blocked === 0,
check: state.check,
summary: { upgrades: plan.upgrades, unchanged: plan.unchanged, blocked: plan.blocked },
items: plan.items.map(item => ({
coordinate: item.coordinate,
registry: item.registry,
currentVersion: item.currentVersion,
remoteVersion: item.remoteVersion,
action: item.action === 'upgrade' && state.executed ? 'upgraded' : item.action,
reason: item.reason,
changedFiles: item.changedFiles,
targets: item.targets
}))
})
}
const heading = state.executed ? 'Upgrade result' : 'Upgrade plan'
return [
`${heading}: ${plan.upgrades} upgrade, ${plan.unchanged} unchanged, ${plan.blocked} blocked`,
...plan.items.map(item => {
const action = item.action === 'upgrade' && state.executed ? 'upgraded' : item.action
const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : ''
const reason = item.reason ? ` (${item.reason})` : ''
return `${action.padEnd(10)} ${item.coordinate}${versions}${reason}`
})
].join('\n')
}
export function renderUpgradeResult(plan: UpgradePlan, result: UpgradeExecutionResult, json: boolean): string {
const executionByCoordinate = new Map(result.items.map(item => [item.coordinate, item]))
const items = plan.items.map(item => ({
coordinate: item.coordinate,
registry: item.registry,
currentVersion: item.currentVersion,
remoteVersion: item.remoteVersion,
action: executionByCoordinate.get(item.coordinate)?.action ?? item.action,
reason: executionByCoordinate.get(item.coordinate)?.reason ?? item.reason,
warnings: executionByCoordinate.get(item.coordinate)?.warnings,
changedFiles: item.changedFiles,
targets: item.targets
}))
if (json) {
return JSON.stringify({
ok: result.failed === 0,
check: false,
summary: {
upgraded: result.upgraded,
unchanged: result.unchanged,
failed: result.failed,
notAttempted: result.notAttempted
},
items
})
}
return [
`Upgrade result: ${result.upgraded} upgraded, ${result.unchanged} unchanged, ${result.failed} failed, ${result.notAttempted} not attempted`,
...items.map(item => {
const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : ''
const reason = item.reason ? ` (${item.reason})` : ''
const warnings = item.warnings?.length ? ` [warning: ${item.warnings.join('; ')}]` : ''
return `${item.action.padEnd(13)} ${item.coordinate}${versions}${reason}${warnings}`
})
].join('\n')
}

View file

@ -11,6 +11,7 @@ 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 { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade'
import { versionCommand } from './commands/version'
import { whoamiCommand } from './commands/whoami'
import { EXIT } from './shared/constants'
@ -247,6 +248,23 @@ cli
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills')
.option('--namespace <slug>', 'Filter a bare slug by namespace')
.option('--agent <profile>', 'Filter installed targets by Agent (repeatable)')
.option('--dir <path>', 'Filter installed targets by directory')
.option('--registry <url>', 'Filter by installation source registry')
.option('--token <token>', 'API token override')
.option('--check', 'Show the exact plan without writing')
.option('--force', 'Replace local changes from the same source')
.option('--json', 'Output JSON')
.action((coordinates: string[], options: UpgradeCommandOptions & { agent?: string | string[] }) => {
return runCommand(
() => upgradeCommand(coordinates, { ...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' })

View file

@ -1,15 +1,22 @@
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { join, relative, resolve } from 'node:path'
import { SkillHubClient } from '../clients/skillhub-client'
import { InventoryStore } from '../stores/inventory-store'
import { InventoryStore, InventoryVersionConflictError } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
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 { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
import {
readInstalledSkillMetadata,
sameInstalledSkillSource,
type InstalledSkillIdentity
} from './installed-skill-metadata'
import type { AgentCandidate } from '../agents/types'
import type { ResolveResponse } from '../clients/skillhub-client'
import type { Inventory } from '../stores/inventory-store'
import { acquireSkillTargetLock } from './skill-target-lock'
export interface InstallOptions {
registry: string
@ -21,19 +28,41 @@ export interface InstallOptions {
force: boolean
home?: string | undefined
resolved?: ResolveResponse | undefined
expectedTargetFiles?: Record<string, Record<string, string>> | undefined
allowTargetDrift?: boolean | undefined
requireExistingTargets?: boolean | undefined
/** Internal test seam for lock lifecycle failures; production uses acquireSkillTargetLock. */
acquireTargetLock?: typeof acquireSkillTargetLock
}
export interface InstallResult {
installed: Array<{ agent: string; dir: string }>
warnings?: string[]
}
interface StagedInstall {
target: AgentCandidate
skillDir: string
canonicalSkillDir: string
tempDir: string
installedAt: string
backupDir: string | null
movedIntoPlace: boolean
}
async function preflightInstallTargets(
targets: AgentCandidate[],
slug: string,
force: boolean
): Promise<Array<{ target: AgentCandidate; skillDir: string }>> {
identity: InstalledSkillIdentity,
force: boolean,
inventory: Inventory
): Promise<Array<{ target: AgentCandidate; skillDir: string; canonicalSkillDir: string }>> {
const seenSkillDirs = new Set<string>()
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = []
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string; canonicalSkillDir: string }> = []
for (const target of targets) {
const canonicalRootDir = await canonicalizeExistingPath(target.rootDir)
const canonicalSkillDir = join(canonicalRootDir, slug)
const rootDir = resolve(target.rootDir)
const canonicalRootDir = await canonicalizeExistingPath(rootDir)
const canonicalSkillDir = join(canonicalRootDir, identity.slug)
if (seenSkillDirs.has(canonicalSkillDir)) {
throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, {
path: canonicalSkillDir,
@ -42,100 +71,311 @@ async function preflightInstallTargets(
}
seenSkillDirs.add(canonicalSkillDir)
const skillDir = join(target.rootDir, slug)
if (await pathExists(skillDir) && !force) {
// Use the canonical path only as an internal identity. Persist the resolved
// user path so macOS aliases and Windows short names remain stable in CLI output.
const resolvedTarget = { ...target, rootDir }
const skillDir = join(rootDir, identity.slug)
const exists = await pathExists(skillDir)
if (exists && !force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
next: 'pass --force to replace a same-source installation'
})
}
preparedTargets.push({ target, skillDir })
if (exists) {
await assertReplaceableInstallation(skillDir, skillDir, canonicalSkillDir, identity, inventory)
}
preparedTargets.push({ target: resolvedTarget, skillDir, canonicalSkillDir })
}
return preparedTargets
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
export async function installSkill(options: InstallOptions): Promise<InstallResult> {
const store = new InventoryStore(options.home)
const inventory = await store.read()
const preparedTargets = await preflightInstallTargets(options.targets, {
registry: options.registry,
namespace: options.namespace,
slug: options.slug
}, options.force, inventory)
const client = new SkillHubClient(options.registry, options.token)
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)
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
const staged: StagedInstall[] = []
try {
for (const { target, skillDir, canonicalSkillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
try {
await extractZip(buffer, tempDir)
for (const { target, skillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
let movedIntoPlace = false
const installedAt = new Date().toISOString()
const snapshot = await snapshotSkillDirectory(tempDir)
if (snapshot.fingerprint !== resolved.fingerprint) {
throw new CliError('downloaded skill fingerprint does not match the resolved release', EXIT.validation, {
coordinate: `@${options.namespace}/${options.slug}`,
expectedFingerprint: resolved.fingerprint,
actualFingerprint: snapshot.fingerprint,
next: 'retry the install after the registry release has been verified'
})
}
const metaDir = join(tempDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
schemaVersion: 1,
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
versionId: resolved.versionId,
fingerprint: resolved.fingerprint,
files: snapshot.files,
source: 'skillhub',
agent: target.agent,
installedAt
}, null, 2))
staged.push({ target, skillDir, canonicalSkillDir, tempDir, installedAt, backupDir: null, movedIntoPlace: false })
} catch (error) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
throw error
}
}
const releases: Array<() => Promise<void>> = []
const warnings: string[] = []
const acquireTargetLock = options.acquireTargetLock ?? acquireSkillTargetLock
try {
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({
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
fingerprint: resolved.fingerprint,
files: snapshot.files,
source: 'skillhub',
agent: target.agent,
installedAt
}, null, 2))
if (await pathExists(skillDir) && !options.force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
for (const item of [...staged].sort((left, right) => left.skillDir.localeCompare(right.skillDir))) {
releases.push(await acquireTargetLock(item.target.rootDir, options.slug))
}
const lockedInventory = await store.read()
await assertNoPartialVersionChange(lockedInventory, staged, {
registry: options.registry,
namespace: options.namespace,
slug: options.slug
}, resolved)
for (const item of staged) {
const targetExists = await pathExists(item.skillDir)
if (!targetExists && options.requireExistingTargets) {
throw new CliError(`installed target disappeared before upgrade commit: ${item.skillDir}`, EXIT.validation, {
path: item.skillDir,
next: 'reinstall the Skill explicitly before upgrading it'
})
}
if (targetExists) {
if (!options.force) {
throw new CliError(`skill already installed at ${item.skillDir}`, EXIT.filesystem, {
path: item.skillDir,
next: 'pass --force to overwrite'
})
}
const backupDir = `${item.skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
await rename(item.skillDir, backupDir)
item.backupDir = backupDir
await assertReplaceableInstallation(item.backupDir, item.skillDir, item.canonicalSkillDir, {
registry: options.registry,
namespace: options.namespace,
slug: options.slug
}, lockedInventory)
const expectedFiles = options.expectedTargetFiles?.[item.skillDir]
if (expectedFiles && !options.allowTargetDrift) {
const currentSnapshot = await snapshotSkillDirectory(item.backupDir)
const changedFiles = diffSkillFiles(expectedFiles, currentSnapshot.files)
if (changedFiles.length > 0) {
throw new CliError(`local changes detected after upgrade planning at ${item.skillDir}`, EXIT.validation, {
path: item.skillDir,
changedFiles,
next: 'review the local changes and retry with --force only if replacement is intended'
})
}
}
}
await rename(item.tempDir, item.skillDir)
item.movedIntoPlace = 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(() => {})
const replacedInstallDirs = await findEquivalentInventoryInstallDirs(
lockedInventory,
new Set(staged.map(item => item.canonicalSkillDir))
)
await store.replaceTargetsAtInstallDirs(
options.registry,
options.namespace,
options.slug,
resolved.version,
staged.map(item => ({
agent: item.target.agent,
rootDir: item.target.rootDir,
installDir: item.skillDir,
installedAt: item.installedAt
})),
resolved.fingerprint,
replacedInstallDirs
)
} 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,
next: 'pass --force to overwrite'
if (error instanceof InventoryVersionConflictError) {
throw new CliError(error.message, EXIT.validation, {
coordinate: `@${options.namespace}/${options.slug}`,
retainedTargets: error.retainedTargets.map(target => ({ agent: target.agent, dir: target.installDir })),
next: 'select all installed targets for the upgrade'
})
}
throw error
}
for (const item of staged) {
if (item.backupDir) await rm(item.backupDir, { recursive: true, force: true }).catch(() => {})
}
} catch (error) {
const rollbackFailures: Array<{ operation: string; path: string; error: string }> = []
for (const item of [...staged].reverse()) {
if (item.movedIntoPlace) {
try {
await rm(item.skillDir, { recursive: true, force: true })
item.movedIntoPlace = false
} catch (rollbackError) {
rollbackFailures.push({
operation: 'remove replacement',
path: item.skillDir,
error: describeError(rollbackError)
})
}
}
if (item.backupDir) {
const backupDir = item.backupDir
try {
await rename(backupDir, item.skillDir)
item.backupDir = null
} catch (rollbackError) {
rollbackFailures.push({
operation: 'restore backup',
path: backupDir,
error: describeError(rollbackError)
})
}
}
}
if (rollbackFailures.length > 0) {
throw new CliError('installation failed and rollback was incomplete', EXIT.filesystem, {
originalError: describeError(error),
rollbackFailures,
retainedBackups: staged.flatMap(item => item.backupDir ? [item.backupDir] : []),
next: 'restore the retained backup directories before retrying'
})
}
throw error
} finally {
if (!movedIntoPlace) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
for (const release of releases.reverse()) {
try {
await release()
} catch (error) {
warnings.push(`target lock cleanup failed: ${describeError(error)}`)
}
}
}
installed.push({ agent: target.agent, dir: skillDir })
return {
installed: staged.map(item => ({ agent: item.target.agent, dir: item.skillDir })),
warnings
}
} finally {
for (const item of staged) {
if (!item.movedIntoPlace) await rm(item.tempDir, { recursive: true, force: true }).catch(() => {})
}
}
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
async function assertNoPartialVersionChange(
inventory: Inventory,
staged: StagedInstall[],
identity: InstalledSkillIdentity,
resolved: ResolveResponse
): Promise<void> {
const item = inventory.items.find(candidate => sameInstalledSkillSource(candidate, identity))
if (!item) return
const selectedInstallDirs = new Set(staged.map(candidate => candidate.canonicalSkillDir))
const retainedTargets: typeof item.targets = []
for (const target of item.targets) {
if (!selectedInstallDirs.has(await canonicalInventoryInstallDir(target))) retainedTargets.push(target)
}
if (retainedTargets.length === 0) return
if (item.version === resolved.version && item.fingerprint === resolved.fingerprint) return
throw new CliError('partial-target install would create inconsistent versions', EXIT.validation, {
coordinate: `@${identity.namespace}/${identity.slug}`,
retainedTargets: retainedTargets.map(target => ({ agent: target.agent, dir: target.installDir })),
next: 'select all installed targets for the upgrade'
})
}
async function assertReplaceableInstallation(
metadataDir: string,
inventoryInstallDir: string,
canonicalInstallDir: string,
identity: InstalledSkillIdentity,
inventory: Inventory
): Promise<void> {
const metadataResult = await readInstalledSkillMetadata(metadataDir)
if (metadataResult.status !== 'valid') {
throw new CliError(`cannot verify SkillHub ownership of ${inventoryInstallDir}`, EXIT.filesystem, {
path: inventoryInstallDir,
reason: metadataResult.status === 'missing' ? 'installation metadata is missing' : metadataResult.reason,
next: 'move or remove the existing directory before installing'
})
}
return { installed }
const inventoryOwners: Inventory['items'] = []
for (const item of inventory.items) {
for (const target of item.targets) {
if (await canonicalInventoryInstallDir(target) === canonicalInstallDir) {
inventoryOwners.push(item)
break
}
}
}
if (!sameInstalledSkillSource(metadataResult.metadata, identity) ||
inventoryOwners.some(owner => !sameInstalledSkillSource(owner, identity))) {
throw new CliError(`source conflict at ${inventoryInstallDir}`, EXIT.filesystem, {
path: inventoryInstallDir,
expected: identity,
actual: {
registry: metadataResult.metadata.registry,
namespace: metadataResult.metadata.namespace,
slug: metadataResult.metadata.slug
},
next: 'choose another target directory or remove the conflicting skill explicitly'
})
}
}
async function findEquivalentInventoryInstallDirs(
inventory: Inventory,
canonicalInstallDirs: Set<string>
): Promise<string[]> {
const matches: string[] = []
for (const item of inventory.items) {
for (const target of item.targets) {
if (canonicalInstallDirs.has(await canonicalInventoryInstallDir(target))) {
matches.push(target.installDir)
}
}
}
return matches
}
async function canonicalInventoryInstallDir(target: { rootDir: string; installDir: string }): Promise<string> {
const resolvedRoot = resolve(target.rootDir)
const canonicalRoot = await canonicalizeExistingPath(resolvedRoot)
return resolve(canonicalRoot, relative(resolvedRoot, resolve(target.installDir)))
}

View file

@ -0,0 +1,82 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { pathExists } from '../platform/paths'
export interface InstalledSkillIdentity {
registry: string
namespace: string
slug: string
}
export interface InstalledSkillMetadata extends InstalledSkillIdentity {
schemaVersion?: number
version: string
versionId?: number
fingerprint?: string
files?: Record<string, string>
source?: string
agent?: string
installedAt?: string
}
export type InstalledMetadataReadResult =
| { status: 'missing' }
| { status: 'invalid'; reason: string }
| { status: 'valid'; metadata: InstalledSkillMetadata }
export async function readInstalledSkillMetadata(skillDir: string): Promise<InstalledMetadataReadResult> {
const metadataPath = join(skillDir, '.skillhub', 'metadata.json')
if (!(await pathExists(metadataPath))) return { status: 'missing' }
try {
const value = JSON.parse(await readFile(metadataPath, 'utf-8')) as unknown
if (!isRecord(value)) return { status: 'invalid', reason: 'metadata root must be an object' }
for (const field of ['registry', 'namespace', 'slug', 'version'] as const) {
if (typeof value[field] !== 'string' || value[field].length === 0) {
return { status: 'invalid', reason: `metadata field "${field}" must be a non-empty string` }
}
}
if (value.source !== undefined && value.source !== 'skillhub') {
return { status: 'invalid', reason: 'metadata field "source" must be "skillhub"' }
}
if (value.schemaVersion !== undefined && value.schemaVersion !== 1) {
return { status: 'invalid', reason: 'metadata schema version is not supported' }
}
if (value.versionId !== undefined &&
(!Number.isInteger(value.versionId) || (value.versionId as number) <= 0)) {
return { status: 'invalid', reason: 'metadata field "versionId" must be a positive integer' }
}
if (value.fingerprint !== undefined && typeof value.fingerprint !== 'string') {
return { status: 'invalid', reason: 'metadata field "fingerprint" must be a string' }
}
if (value.files !== undefined && !isStringRecord(value.files)) {
return { status: 'invalid', reason: 'metadata field "files" must map paths to hashes' }
}
return { status: 'valid', metadata: value as unknown as InstalledSkillMetadata }
} catch {
return { status: 'invalid', reason: 'metadata is not valid JSON' }
}
}
export function sameInstalledSkillSource(
left: InstalledSkillIdentity,
right: InstalledSkillIdentity
): boolean {
return normalizeRegistry(left.registry) === normalizeRegistry(right.registry) &&
left.namespace === right.namespace &&
left.slug === right.slug
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every(entry => typeof entry === 'string')
}

View file

@ -3,6 +3,7 @@ import { relative, isAbsolute } from 'node:path'
import { InventoryStore } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { acquireSkillTargetLock } from './skill-target-lock'
/**
* Validate that child path is strictly under parent directory.
@ -52,8 +53,9 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise<Rem
}
const removed: RemoveResult['removed'] = []
const releases: Array<() => Promise<void>> = []
for (const { item, target } of targetsToRemove) {
for (const { target } of targetsToRemove) {
// Validate installDir is strictly under the recorded rootDir
if (!target.rootDir || !isPathUnder(target.installDir, target.rootDir)) {
throw new CliError(`unsafe remove path: ${target.installDir} is not under ${target.rootDir ?? 'unknown root'}`, EXIT.filesystem, {
@ -61,20 +63,31 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise<Rem
next: 'verify inventory integrity with `skillhub doctor`'
})
}
}
let existed = true
try {
await stat(target.installDir)
} catch {
existed = false
try {
for (const { target } of [...targetsToRemove]
.sort((left, right) => left.target.installDir.localeCompare(right.target.installDir))) {
releases.push(await acquireSkillTargetLock(target.rootDir, options.slug))
}
if (existed) {
await rm(target.installDir, { recursive: true })
}
for (const { item, target } of targetsToRemove) {
let existed = true
try {
await stat(target.installDir)
} catch {
existed = false
}
await store.removeTarget(options.registry, item.namespace, options.slug, target.installDir)
removed.push({ namespace: item.namespace, agent: target.agent, dir: target.installDir, existed })
if (existed) {
await rm(target.installDir, { recursive: true })
}
await store.removeTarget(options.registry, item.namespace, options.slug, target.installDir)
removed.push({ namespace: item.namespace, agent: target.agent, dir: target.installDir, existed })
}
} finally {
for (const release of releases.reverse()) await release()
}
return { removed }

View file

@ -0,0 +1,79 @@
import { createHash } from 'node:crypto'
import { chmod, lstat, mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { lock } from 'proper-lockfile'
import { canonicalizeExistingPath } from '../platform/paths'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
/** Serializes every local lifecycle mutation for one Skill target directory. */
export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise<void>> {
const lockPath = await skillTargetLockPath(rootDir, slug)
try {
return await lock(lockPath, {
lockfilePath: lockPath,
realpath: false,
stale: 10_000,
update: 3_000,
retries: 0
})
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') {
throw targetBusyError(rootDir, slug)
}
throw error
}
}
export async function skillTargetLockPath(rootDir: string, slug: string): Promise<string> {
const canonicalRoot = await canonicalizeExistingPath(resolve(rootDir))
const target = resolve(canonicalRoot, slug)
const digest = createHash('sha256').update(target).digest('hex')
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user'
const lockDir = join(tmpdir(), `skillhub-cli-target-locks-${uid}`)
await ensurePrivateLockDir(lockDir)
return join(lockDir, `${digest}.lock`)
}
interface LockDirectoryDetails {
isDirectory(): boolean
isSymbolicLink(): boolean
uid: number
mode: number
}
export function assertPrivateLockDir(
lockDir: string,
details: LockDirectoryDetails,
currentUid: number | null
): void {
if (!details.isDirectory() || details.isSymbolicLink()) {
throw new Error(`unsafe SkillHub CLI lock directory: ${lockDir}`)
}
if (currentUid !== null && details.uid !== currentUid) {
throw new Error(`SkillHub CLI lock directory is owned by another user: ${lockDir}`)
}
}
export async function ensurePrivateLockDir(lockDir: string): Promise<void> {
try {
await mkdir(lockDir, { mode: 0o700 })
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
}
const details = await lstat(lockDir)
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null
assertPrivateLockDir(lockDir, details, currentUid)
if (process.platform !== 'win32' && (details.mode & 0o077) !== 0) {
await chmod(lockDir, 0o700)
}
}
function targetBusyError(rootDir: string, slug: string): CliError {
return new CliError(`install target is busy: ${join(rootDir, slug)}`, EXIT.filesystem, {
path: join(rootDir, slug),
next: 'wait for the other SkillHub CLI process to finish and retry'
})
}

View file

@ -0,0 +1,11 @@
import { compare as compareSemver, valid as validSemver } from 'semver'
export type SkillVersionOrder = 'same' | 'remote-newer' | 'remote-older' | 'unknown'
export function compareSkillVersions(installedVersion: string, remoteVersion: string): SkillVersionOrder {
if (installedVersion === remoteVersion) return 'same'
if (!validSemver(installedVersion) || !validSemver(remoteVersion)) return 'unknown'
const order = compareSemver(remoteVersion, installedVersion)
if (order === 0) return 'same'
return order > 0 ? 'remote-newer' : 'remote-older'
}

View file

@ -7,8 +7,9 @@ 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'
import { compareSkillVersions } from './skill-version-order'
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed'
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'blocked' | 'orphaned' | 'not-installed'
export interface SkillSyncMetadata {
registry: string
@ -36,6 +37,7 @@ export interface PullResult {
entries: SyncStatusEntry[]
actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }>
failures: Array<{ slug: string; message: string }>
warnings: Array<{ slug: string; message: string }>
}
export interface PushResultItem {
@ -87,15 +89,46 @@ export async function inspectNamespaceWorkspace(options: {
}
const snapshot = await snapshotSkillDirectory(skillDir)
const changedFiles = snapshot.fingerprint === metadata.fingerprint
? []
: diffSkillFiles(metadata.files, snapshot.files)
const versionOrder = compareSkillVersions(metadata.version, remote.version)
if (versionOrder === 'remote-older') {
entries.push({
...baseEntry(remote, 'blocked'),
localVersion: metadata.version,
changedFiles,
reason: 'remote version is older than the installed version; local files were kept'
})
continue
}
if (versionOrder === 'unknown') {
entries.push({
...baseEntry(remote, 'blocked'),
localVersion: metadata.version,
changedFiles,
reason: 'cannot determine version order; use explicit install after verifying the release'
})
continue
}
if (versionOrder === 'same' && metadata.fingerprint !== remote.fingerprint) {
entries.push({
...baseEntry(remote, 'blocked'),
localVersion: metadata.version,
changedFiles,
reason: 'remote content changed without a newer version; use explicit install after verifying the release'
})
continue
}
if (snapshot.fingerprint !== metadata.fingerprint) {
entries.push({
...baseEntry(remote, 'local-changed'),
localVersion: metadata.version,
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
changedFiles
})
continue
}
if (metadata.fingerprint !== remote.fingerprint) {
if (versionOrder === 'remote-newer') {
entries.push({
...baseEntry(remote, 'update-available'),
localVersion: metadata.version
@ -133,6 +166,7 @@ export async function pullNamespace(options: {
check: boolean
prune: boolean
force: boolean
installSkillFn?: typeof installSkill
}): Promise<PullResult> {
const inspected = await inspectNamespaceWorkspace(options)
const result: PullResult = {
@ -140,13 +174,17 @@ export async function pullNamespace(options: {
rootDir: options.rootDir,
entries: inspected.entries,
actions: [],
failures: []
failures: inspected.entries
.filter(entry => entry.status === 'blocked')
.map(entry => ({ slug: entry.slug, message: entry.reason ?? 'automatic sync is blocked' })),
warnings: []
}
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 === 'blocked') 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
@ -154,7 +192,7 @@ export async function pullNamespace(options: {
const remote = remoteBySlug.get(entry.slug)
if (!remote) continue
try {
await installSkill({
const installed = await (options.installSkillFn ?? installSkill)({
registry: options.registry,
token: options.token,
namespace: options.namespace,
@ -172,6 +210,7 @@ export async function pullNamespace(options: {
force: entry.status !== 'not-installed' || options.force
})
result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' })
result.warnings.push(...(installed.warnings ?? []).map(message => ({ slug: entry.slug, message })))
} catch (error) {
result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' })
}

View file

@ -0,0 +1,409 @@
import { isAbsolute, relative, resolve } from 'node:path'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import { SkillHubClient, type ResolveResponse } from '../clients/skillhub-client'
import { InventoryStore, type InventoryItem, type InventoryTarget } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { hasExplicitNamespace, parseSkillName, resolveSkillName } from '../shared/skill-name-parser'
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
import { installSkill } from './install-service'
import { readInstalledSkillMetadata, sameInstalledSkillSource } from './installed-skill-metadata'
import { compareSkillVersions } from './skill-version-order'
const MAX_UPGRADE_SELECTION = 50
export interface UpgradeSelectionOptions {
coordinates: string[]
namespace?: string | undefined
registry?: string | undefined
agents?: string[] | undefined
dir?: string | undefined
force: boolean
home?: string
tokenForRegistry: (registry: string) => Promise<string | undefined>
}
export type UpgradePlanAction = 'upgrade' | 'unchanged' | 'blocked'
export interface UpgradePlanItem {
coordinate: string
registry: string
currentVersion: string
remoteVersion?: string
action: UpgradePlanAction
reason?: string
changedFiles: string[]
targets: Array<{ agent: string; dir: string }>
resolved?: ResolveResponse
inventoryItem: InventoryItem
selectedTargets: InventoryTarget[]
expectedTargetFiles: Record<string, Record<string, string>>
allowTargetDrift: boolean
}
export interface UpgradePlan {
items: UpgradePlanItem[]
blocked: number
upgrades: number
unchanged: number
}
export type UpgradeExecutionAction = 'upgraded' | 'unchanged' | 'failed' | 'not-attempted'
export interface UpgradeExecutionResult {
items: Array<{
coordinate: string
action: UpgradeExecutionAction
reason?: string
warnings?: string[]
exitCode?: number
}>
upgraded: number
unchanged: number
failed: number
notAttempted: number
}
type UpgradeExecutionOptions = Pick<UpgradeSelectionOptions, 'home' | 'tokenForRegistry'> & {
installSkillFn?: typeof installSkill
}
export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promise<UpgradePlan> {
if (options.coordinates.length === 0) {
throw new CliError('provide at least one installed skill coordinate', EXIT.usage)
}
if (options.coordinates.length > MAX_UPGRADE_SELECTION) {
throw new CliError(`upgrade accepts at most ${MAX_UPGRADE_SELECTION} coordinates`, EXIT.usage)
}
const store = new InventoryStore(options.home)
const inventory = await store.read()
const selected = selectInventoryItems(inventory.items, options)
const items: UpgradePlanItem[] = []
for (const selection of selected) {
const coordinate = `@${selection.item.namespace}/${selection.item.slug}`
const base = {
coordinate,
registry: selection.item.registry,
currentVersion: selection.item.version,
changedFiles: [] as string[],
targets: selection.targets.map(target => ({ agent: target.agent, dir: target.installDir })),
inventoryItem: selection.item,
selectedTargets: selection.targets,
expectedTargetFiles: {} as Record<string, Record<string, string>>,
allowTargetDrift: options.force
}
if (selection.targets.length !== selection.item.targets.length) {
items.push({
...base,
action: 'blocked',
reason: 'partial-target upgrades are not supported because one inventory item has one shared version'
})
continue
}
let resolved: ResolveResponse
try {
const token = await options.tokenForRegistry(selection.item.registry)
resolved = await new SkillHubClient(selection.item.registry, token)
.resolve(selection.item.namespace, selection.item.slug)
} catch (error) {
items.push({
...base,
action: 'blocked',
reason: error instanceof Error ? error.message : 'remote version unavailable'
})
continue
}
if (resolved.namespace !== selection.item.namespace || resolved.slug !== selection.item.slug) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'registry resolved a different skill identity'
})
continue
}
const inspection = await inspectTargets(selection.item, selection.targets)
const hardConflict = inspection.hardConflicts[0]
if (hardConflict) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: hardConflict,
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
if (inspection.changedFiles.length > 0 && !options.force) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'local changes detected; pass --force to replace same-source files',
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
if (inspection.baselineMissing && !options.force) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'installed metadata has no file baseline; pass --force to migrate this same-source installation',
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
const versionOrder = compareSkillVersions(selection.item.version, resolved.version)
if (versionOrder === 'remote-older') {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'remote version is older than the installed version; local files were kept',
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
if (versionOrder === 'unknown') {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'cannot determine version order; use explicit install after verifying the release',
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
const unchanged = versionOrder === 'same' &&
selection.item.fingerprint === resolved.fingerprint &&
inspection.metadataCurrent
if (versionOrder === 'same' && !unchanged) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'remote content changed without a newer version; use explicit install after verifying the release',
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
continue
}
items.push({
...base,
remoteVersion: resolved.version,
action: unchanged ? 'unchanged' : 'upgrade',
changedFiles: inspection.changedFiles,
expectedTargetFiles: inspection.currentFiles,
resolved
})
}
return {
items,
blocked: items.filter(item => item.action === 'blocked').length,
upgrades: items.filter(item => item.action === 'upgrade').length,
unchanged: items.filter(item => item.action === 'unchanged').length
}
}
export async function executeSkillUpgradePlan(
plan: UpgradePlan,
options: UpgradeExecutionOptions
): Promise<UpgradeExecutionResult> {
if (plan.blocked > 0) {
throw new CliError('upgrade plan contains blocked skills', EXIT.validation)
}
const items: UpgradeExecutionResult['items'] = []
let stopped = false
for (const item of plan.items) {
if (item.action === 'unchanged') {
items.push({ coordinate: item.coordinate, action: 'unchanged' })
continue
}
if (item.action !== 'upgrade' || !item.resolved) continue
if (stopped) {
items.push({ coordinate: item.coordinate, action: 'not-attempted' })
continue
}
try {
const token = await options.tokenForRegistry(item.registry)
const installed = await (options.installSkillFn ?? installSkill)({
registry: item.registry,
token,
namespace: item.inventoryItem.namespace,
slug: item.inventoryItem.slug,
resolved: item.resolved,
targets: item.selectedTargets.map(target => ({
agent: target.agent,
rootDir: target.rootDir,
scope: 'project',
source: 'explicit'
})),
force: true,
home: options.home,
expectedTargetFiles: item.expectedTargetFiles,
allowTargetDrift: item.allowTargetDrift,
requireExistingTargets: true
})
items.push({
coordinate: item.coordinate,
action: 'upgraded',
...(installed.warnings?.length ? { warnings: installed.warnings } : {})
})
} catch (error) {
items.push({
coordinate: item.coordinate,
action: 'failed',
reason: error instanceof Error ? error.message : 'unexpected upgrade failure',
...(error instanceof CliError ? { exitCode: error.exitCode } : {})
})
stopped = true
}
}
return {
items,
upgraded: items.filter(item => item.action === 'upgraded').length,
unchanged: items.filter(item => item.action === 'unchanged').length,
failed: items.filter(item => item.action === 'failed').length,
notAttempted: items.filter(item => item.action === 'not-attempted').length
}
}
function selectInventoryItems(
items: InventoryItem[],
options: Pick<UpgradeSelectionOptions, 'coordinates' | 'namespace' | 'registry' | 'agents' | 'dir'>
): Array<{ item: InventoryItem; targets: InventoryTarget[] }> {
const selected = new Map<string, { item: InventoryItem; targets: InventoryTarget[] }>()
for (const coordinate of options.coordinates) {
const explicitNamespace = hasExplicitNamespace(coordinate)
const parsed = explicitNamespace
? resolveSkillName(coordinate, options.namespace)
: parseSkillName(coordinate)
const namespace = explicitNamespace ? parsed.namespace : options.namespace
const matches = items.flatMap(item => {
if (item.slug !== parsed.slug) return []
if (namespace && item.namespace !== namespace) return []
if (options.registry && normalizeRegistry(item.registry) !== normalizeRegistry(options.registry)) return []
const targets = item.targets.filter(target => matchesTargetFilters(target, options.agents, options.dir))
return targets.length > 0 ? [{ item, targets }] : []
})
if (matches.length === 0) {
throw new CliError(`skill "${coordinate}" is not installed`, EXIT.usage, {
next: `use skillhub install ${coordinate}`
})
}
if (matches.length > 1) {
throw new CliError(`installed skill "${coordinate}" is ambiguous`, EXIT.usage, {
matches: matches.map(match => `${match.item.registry} @${match.item.namespace}/${match.item.slug}`),
next: 'use a full coordinate and --registry to select one installation source'
})
}
const match = matches[0]!
const key = `${normalizeRegistry(match.item.registry)}\u0000${match.item.namespace}\u0000${match.item.slug}`
selected.set(key, match)
}
if (selected.size > MAX_UPGRADE_SELECTION) {
throw new CliError(`upgrade resolves to at most ${MAX_UPGRADE_SELECTION} skills`, EXIT.usage)
}
return [...selected.values()]
}
async function inspectTargets(item: InventoryItem, targets: InventoryTarget[]): Promise<{
hardConflicts: string[]
changedFiles: string[]
baselineMissing: boolean
metadataCurrent: boolean
currentFiles: Record<string, Record<string, string>>
}> {
const hardConflicts: string[] = []
const changedFiles = new Set<string>()
let baselineMissing = false
let metadataCurrent = true
const currentFiles: Record<string, Record<string, string>> = {}
for (const target of targets) {
if (!isAbsolute(target.rootDir) || !isAbsolute(target.installDir)) {
hardConflicts.push(`legacy relative target path is unsafe to upgrade: ${target.installDir}`)
continue
}
const installDir = await canonicalizeExistingPath(target.installDir)
if (!(await pathExists(installDir))) {
hardConflicts.push(`installed target is missing: ${target.installDir}`)
continue
}
const result = await readInstalledSkillMetadata(installDir)
if (result.status !== 'valid') {
hardConflicts.push(`metadata-invalid at ${target.installDir}: ${result.status === 'missing' ? 'missing' : result.reason}`)
continue
}
if (!sameInstalledSkillSource(result.metadata, item)) {
hardConflicts.push(`source-conflict at ${target.installDir}`)
continue
}
if (!result.metadata.files) {
baselineMissing = true
} else {
const snapshot = await snapshotSkillDirectory(installDir)
currentFiles[target.installDir] = snapshot.files
for (const path of diffSkillFiles(result.metadata.files, snapshot.files)) {
changedFiles.add(`${target.installDir}:${path}`)
}
}
if (result.metadata.version !== item.version || result.metadata.fingerprint !== item.fingerprint) {
metadataCurrent = false
}
}
return {
hardConflicts,
changedFiles: [...changedFiles].sort(),
baselineMissing,
metadataCurrent,
currentFiles
}
}
function matchesTargetFilters(target: InventoryTarget, agents?: string[], dir?: string): boolean {
if (agents?.length && !agents.includes(target.agent)) return false
if (!dir) return true
const filterPath = resolve(dir)
const installPath = resolve(target.installDir)
const rootPath = resolve(target.rootDir)
return isSameOrWithin(filterPath, installPath) || isSameOrWithin(filterPath, rootPath)
}
function isSameOrWithin(parent: string, candidate: string): boolean {
const rel = relative(parent, candidate)
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\'))
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}

View file

@ -1,5 +1,6 @@
import { open, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { lock } from 'proper-lockfile'
import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths'
export interface InventoryTarget {
@ -22,6 +23,13 @@ export interface Inventory {
items: InventoryItem[]
}
export class InventoryVersionConflictError extends Error {
constructor(readonly retainedTargets: InventoryTarget[]) {
super('partial-target install would create inconsistent versions')
this.name = 'InventoryVersionConflictError'
}
}
export class InventoryStore {
readonly path: string
@ -41,76 +49,56 @@ export class InventoryStore {
async writeAtomic(inventory: Inventory): Promise<void> {
await ensureDir(dirname(this.path))
let release: (() => Promise<void>) | null = null
try {
release = await this.acquireLock()
await this.writeUnderLock(inventory)
} finally {
if (release) await release().catch(() => {})
}
}
private async mutateAtomic<T>(mutate: (inventory: Inventory) => T): Promise<T> {
await ensureDir(dirname(this.path))
let release: (() => Promise<void>) | null = null
try {
release = await this.acquireLock()
const inventory = await this.read()
const result = mutate(inventory)
await this.writeUnderLock(inventory)
return result
} finally {
if (release) await release().catch(() => {})
}
}
private async writeUnderLock(inventory: Inventory): Promise<void> {
const payload = JSON.stringify(inventory, null, 2)
JSON.parse(payload)
const lockPath = `${this.path}.lock`
const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
let lockHandle: Awaited<ReturnType<typeof open>> | null = null
try {
// Acquire exclusive lock with retry and stale lock detection
lockHandle = await this.acquireLock(lockPath)
await writeFile(tmpPath, payload)
JSON.parse(await readFile(tmpPath, 'utf-8'))
await rename(tmpPath, this.path)
} finally {
// Clean up temp file if it still exists
await rm(tmpPath, { force: true }).catch(() => {})
// Release lock
if (lockHandle) {
await lockHandle.close().catch(() => {})
await rm(lockPath, { force: true }).catch(() => {})
}
}
}
private async acquireLock(lockPath: string, maxRetries = 10, retryDelayMs = 100): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Try to create lock file with PID and timestamp
const lockHandle = await open(lockPath, 'wx')
const lockData = JSON.stringify({ pid: process.pid, timestamp: Date.now() })
await writeFile(lockPath, lockData)
return lockHandle
} catch (err) {
if (err instanceof Error && 'code' in err && err.code !== 'EEXIST') throw err
// Lock exists, check if it's stale (older than 30 seconds)
// 30s threshold chosen to balance between:
// - Allowing slow operations to complete (e.g., large inventory writes)
// - Recovering quickly from crashed processes
try {
const lockContent = await readFile(lockPath, 'utf-8')
const lockData = JSON.parse(lockContent) as { pid: number; timestamp: number }
const ageMs = Date.now() - lockData.timestamp
if (ageMs > 30000) {
// Stale lock detected - verify the process is actually dead
try {
// process.kill(pid, 0) throws if process doesn't exist
process.kill(lockData.pid, 0)
// Process still alive, wait and retry
} catch {
// Process is dead, safe to remove stale lock
await rm(lockPath, { force: true }).catch(() => {})
continue
}
}
} catch {
// Lock file disappeared or corrupted, retry
continue
}
// Lock is held by another active process, wait and retry with exponential backoff
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs * Math.pow(2, attempt)))
}
private acquireLock(): Promise<() => Promise<void>> {
return lock(this.path, {
lockfilePath: `${this.path}.lock`,
realpath: false,
stale: 30_000,
update: 10_000,
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 1_000,
randomize: true
}
}
throw new Error(`Failed to acquire lock after ${maxRetries} attempts`)
})
}
async upsertTarget(
@ -121,52 +109,43 @@ export class InventoryStore {
target: InventoryTarget,
fingerprint?: string
): Promise<void> {
const inventory = await this.read()
const existing = inventory.items.find(
i => i.registry === registry && i.namespace === namespace && i.slug === slug
)
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
} else {
item.targets.push(target)
}
await this.writeAtomic(inventory)
await this.mutateAtomic(inventory => {
const existing = inventory.items.find(
i => i.registry === registry && i.namespace === namespace && i.slug === slug
)
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
else item.targets.push(target)
})
}
async removeTarget(registry: string, namespace: string, slug: string, installDir: string): Promise<boolean> {
const inventory = await this.read()
const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug)
if (!item) return false
const idx = item.targets.findIndex(t => t.installDir === installDir)
if (idx < 0) return false
item.targets.splice(idx, 1)
if (item.targets.length === 0) {
inventory.items = inventory.items.filter(i => i !== item)
}
await this.writeAtomic(inventory)
return true
return this.mutateAtomic(inventory => {
const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug)
if (!item) return false
const idx = item.targets.findIndex(t => t.installDir === installDir)
if (idx < 0) return false
item.targets.splice(idx, 1)
if (item.targets.length === 0) inventory.items = inventory.items.filter(i => i !== item)
return true
})
}
async removeTargetsByInstallDir(installDir: string): Promise<number> {
const inventory = await this.read()
let removed = 0
for (const item of inventory.items) {
const before = item.targets.length
item.targets = item.targets.filter(t => t.installDir !== installDir)
removed += before - item.targets.length
}
if (removed > 0) {
return this.mutateAtomic(inventory => {
let removed = 0
for (const item of inventory.items) {
const before = item.targets.length
item.targets = item.targets.filter(t => t.installDir !== installDir)
removed += before - item.targets.length
}
inventory.items = inventory.items.filter(item => item.targets.length > 0)
await this.writeAtomic(inventory)
}
return removed
return removed
})
}
async replaceTargetAtInstallDir(
@ -177,21 +156,60 @@ export class InventoryStore {
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)
await this.mutateAtomic(inventory => {
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)
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)
})
}
async replaceTargetsAtInstallDirs(
registry: string,
namespace: string,
slug: string,
version: string,
targets: InventoryTarget[],
fingerprint?: string,
replacedInstallDirs: string[] = []
): Promise<void> {
await this.mutateAtomic(inventory => {
const installDirs = new Set([
...targets.map(target => target.installDir),
...replacedInstallDirs
])
const existingItem = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
const retainedTargets = existingItem?.targets.filter(target => !installDirs.has(target.installDir)) ?? []
if (existingItem && retainedTargets.length > 0 &&
(existingItem.version !== version || existingItem.fingerprint !== fingerprint)) {
throw new InventoryVersionConflictError(retainedTargets)
}
for (const item of inventory.items) {
item.targets = item.targets.filter(existing => !installDirs.has(existing.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(...targets)
})
}
}

View file

@ -1,3 +1,6 @@
import { createHash } from 'node:crypto'
import { unzipSync } from 'fflate'
type FakeHandler = (req: Request) => Response | Promise<Response>
export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
@ -68,12 +71,25 @@ export interface FakeSkill {
version?: string
/** Numeric version id returned in resolve. Defaults to 1. */
versionId?: number
/** SHA-256 fingerprint string. Defaults to 'deadbeef'. */
/** SHA-256 fingerprint string. Defaults to the fingerprint of zipBytes. */
fingerprint?: string
/** Raw bytes served as the ZIP body. Defaults to a minimal valid ZIP. */
zipBytes?: Uint8Array
}
function resolveSkillFingerprint(skill: FakeSkill): string {
if (skill.fingerprint) return skill.fingerprint
const entries = unzipSync(skill.zipBytes ?? MINIMAL_ZIP)
const aggregate = createHash('sha256')
for (const path of Object.keys(entries)
.filter(path => !path.endsWith('/') && path !== '.skillhub' && !path.startsWith('.skillhub/'))
.sort((left, right) => left.localeCompare(right))) {
const fileHash = createHash('sha256').update(entries[path]!).digest('hex')
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
}
return `sha256:${aggregate.digest('hex')}`
}
// Minimal valid ZIP: local file header + end-of-central-directory record with
// zero entries. Enough for any consumer that just checks Content-Type / length.
const MINIMAL_ZIP = new Uint8Array([
@ -103,6 +119,7 @@ export interface CapturedPublish {
/** Visibility string from the multipart form field. */
visibility: string
rejectExistingVersion: boolean
archiveEntries: string[]
}
export interface CapturedValidate {
@ -203,7 +220,9 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
delete: CapturedDelete | null
validate: CapturedValidate | null
review: CapturedReview | null
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
resolves: number
downloads: number
} = { publish: null, resolve: null, delete: null, validate: null, review: null, resolves: 0, downloads: 0 }
// 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
@ -291,7 +310,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
slug: skill.slug,
version: skill.version ?? '1.0.0',
versionId: skill.versionId ?? index + 1,
fingerprint: skill.fingerprint ?? 'deadbeef',
fingerprint: resolveSkillFingerprint(skill),
updatedAt: '2026-08-18T00:00:00Z',
visibility: 'NAMESPACE_ONLY',
downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0')
@ -308,6 +327,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
// Resolve: GET /api/cli/v1/skills/:namespace/:slug/resolve
const resolveMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch && req.method === 'GET') {
state.resolves++
if (options.failures?.resolve) return failureResponse(options.failures.resolve)
const namespace = resolveMatch[1]!
const slug = resolveMatch[2]!
@ -329,7 +349,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
slug,
version,
versionId: skill.versionId ?? 1,
fingerprint: skill.fingerprint ?? 'deadbeef',
fingerprint: resolveSkillFingerprint(skill),
downloadUrl: buildDownloadUrl(baseUrl, namespace, slug, version)
}
})
@ -345,6 +365,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
state.downloads += 1
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
@ -364,6 +385,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
state.downloads += 1
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
@ -415,6 +437,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = {
namespace,
fileName,
@ -442,7 +465,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
const namespace = publishMatch[1]!
// Parse multipart form data asynchronously — return a Promise<Response>.
return req.formData().then(form => {
return req.formData().then(async form => {
const fileField = form.get('file')
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
@ -451,13 +474,17 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
const archiveEntries = fileField instanceof File
? Object.keys(unzipSync(new Uint8Array(await fileField.arrayBuffer()))).sort()
: []
// Record for test assertions.
state.publish = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
rejectExistingVersion: form.get('rejectExistingVersion') === 'true',
archiveEntries
}
return Response.json({

View file

@ -0,0 +1,39 @@
import { access, writeFile } from 'node:fs/promises'
import { acquireSkillTargetLock } from '../../src/services/skill-target-lock'
import { CliError } from '../../src/shared/errors'
const [rootDir, slug, readyPath, startPath, acquiredPath, releasePath] = process.argv.slice(2)
if (!rootDir || !slug || !readyPath || !startPath || !acquiredPath || !releasePath) process.exit(5)
try {
await writeFile(readyPath, 'ready')
let startRequested = false
while (!startRequested) {
try {
await access(startPath)
startRequested = true
} catch {
await new Promise(resolve => setTimeout(resolve, 10))
}
}
const release = await acquireSkillTargetLock(rootDir, slug)
await writeFile(acquiredPath, 'acquired')
process.stdout.write('acquired\n')
let releaseRequested = false
while (!releaseRequested) {
try {
await access(releasePath)
releaseRequested = true
} catch {
await new Promise(resolve => setTimeout(resolve, 10))
}
}
await release()
process.exit(0)
} catch (error) {
if (error instanceof CliError) {
process.stderr.write(`${error.message}\n`)
process.exit(error.exitCode)
}
throw error
}

View file

@ -9,7 +9,7 @@
* The unit test in test/unit/stores/inventory-store.test.ts pins the
* single-process lock recovery; here we cover the cross-process case.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, readFile, readdir, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
@ -28,17 +28,7 @@ function makeSkillZip(): Uint8Array {
}
describe('cross-process concurrency on inventory.json', () => {
// KNOWN BUG (documented here, not yet fixed):
// inventory-store.upsertTarget() reads inventory, modifies in memory,
// then writeAtomic() acquires the lock only over the write half. Two
// concurrent installs each read the (empty) inventory, each adds their
// own item, and the second writer overwrites the first — a classic
// lost-update.
//
// When the fix lands (lock spans read+write, or upsertTarget acquires
// the lock first and re-reads), tighten the inventory assertion to
// `expect(slugs).toEqual(['first', 'second'])`.
test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => {
test('two parallel installs recover the same stale lock and preserve both inventory items', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
@ -50,6 +40,11 @@ describe('cross-process concurrency on inventory.json', () => {
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const staleLockPath = join(env.home, '.skillhub', 'inventory.json.lock')
await mkdir(staleLockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(staleLockPath, staleTime, staleTime)
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
@ -66,9 +61,6 @@ describe('cross-process concurrency on inventory.json', () => {
)
])
// Both subprocess installs report success — neither errored at the
// protocol level even though the inventory bookkeeping race ate one of
// their inventory writes.
expect(r1.exitCode).toBe(0)
expect(r2.exitCode).toBe(0)
@ -80,12 +72,10 @@ describe('cross-process concurrency on inventory.json', () => {
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
const slugs = inv.items.map(i => i.slug).sort()
// Today: at least one slug always lands; under the lost-update race
// both may NOT be there. When the lock widens to cover read+write,
// upgrade this to `toEqual(['first', 'second'])`.
expect(slugs.length).toBeGreaterThanOrEqual(1)
const lastSlug = slugs[slugs.length - 1]!
expect(['first', 'second']).toContain(lastSlug)
expect(slugs).toEqual(['first', 'second'])
await expect(access(staleLockPath)).rejects.toThrow()
expect((await readdir(join(env.home, '.skillhub')))
.filter(name => name.startsWith('inventory.json.') && name.endsWith('.tmp'))).toEqual([])
})
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
@ -111,15 +101,8 @@ describe('cross-process concurrency on inventory.json', () => {
)
])
// Two valid outcomes: (a) both succeed because the loser's existence
// check ran BEFORE the winner extracted, OR (b) one succeeds and the
// other reports already-installed (EXIT.filesystem).
// Either way, inventory must end up coherent (single item, single
// target — no duplicates).
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
expect(codes[0]).toBe(0) // at least one succeeded
const otherCode = codes[1]!
expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict
expect(codes).toEqual([0, 4])
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
@ -138,14 +121,13 @@ describe('cross-process concurrency on inventory.json', () => {
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Plant a stale lock file: PID 1 (init, never the same as our test
// child, and won't match the spawned subprocess's PID), with a very
// old timestamp so the store treats it as stale.
// Plant a stale proper-lockfile directory.
const skillhubDir = join(env.home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const lockPath = join(skillhubDir, 'inventory.json.lock')
const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold
await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp }))
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const installDir = join(env.cwd, 'stale')
await mkdir(installDir, { recursive: true })

View file

@ -34,6 +34,23 @@ describe('help command', () => {
expect(result.stdout).toContain('skillhub search')
})
test('distinguishes skill upgrade from CLI self-update and namespace sync', async () => {
const upgrade = await runCli(['help', 'upgrade'])
expect(upgrade.exitCode).toBe(0)
expect(upgrade.stdout).toContain('Upgrade explicitly selected installed skills')
expect(upgrade.stdout).toContain('skillhub upgrade <coordinate...>')
expect(upgrade.stdout).toContain('--check')
expect(upgrade.stdout).toContain('--force')
const update = await runCli(['help', 'update'])
expect(update.exitCode).toBe(0)
expect(update.stdout).toContain('Check or update CLI itself')
const sync = await runCli(['help', 'sync'])
expect(sync.exitCode).toBe(0)
expect(sync.stdout).toContain('namespace workspaces')
})
// P1: bare `skillhub help` (no topic) prints the directory of all commands
test('bare help lists all commands in human format', async () => {
const result = await runCli(['help'])

View file

@ -42,7 +42,6 @@ describe('install command — P0', () => {
slug: 'pdf-parser',
version: '1.0.0',
versionId: 1,
fingerprint: 'abc123',
zipBytes: makeSkillZip()
}
]

View file

@ -1,4 +1,4 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
@ -117,6 +117,25 @@ describe('publish command — P0', () => {
expect(registry.received.publish!.visibility).toBe('PUBLIC')
})
test('directory publish excludes local SkillHub installation metadata', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '# Demo'])
await mkdir(join(dir, '.skillhub'), { recursive: true })
await writeFile(join(dir, '.skillhub', 'metadata.json'), '{"registry":"private"}')
const result = await runCli(['publish', dir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(registry.received.publish!.archiveEntries).toContain('SKILL.md')
expect(registry.received.publish!.archiveEntries.some(path => path.startsWith('.skillhub'))).toBe(false)
})
test('zip file happy path: exit 0, fileName matches passed file', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
@ -242,7 +261,6 @@ describe('publish command — P1', () => {
// P1 — content shape: directory layout and edge files
// ---------------------------------------------------------------------------
import { mkdir } from 'node:fs/promises'
import { unzipSync, strFromU8 } from 'fflate'
describe('publish command — content shape', () => {

View file

@ -6,6 +6,9 @@ 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'
import { SkillHubClient } from '../../src/clients/skillhub-client'
import { pullNamespace } from '../../src/services/sync-service'
import { renderPullResult } from '../../src/commands/sync'
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
const content = strToU8(body)
@ -15,6 +18,43 @@ function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string }
}
describe('sync command', () => {
test('pull propagates committed install warnings', 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 {
const result = await pullNamespace({
client: new SkillHubClient(registry.url, 'token'),
registry: registry.url,
token: 'token',
namespace: 'team-a',
rootDir: skillsDir,
check: false,
prune: false,
force: false,
installSkillFn: async () => ({
installed: [{ agent: 'workspace', dir: join(skillsDir, 'demo') }],
warnings: ['target lock cleanup failed: simulated release failure']
})
})
expect(result.actions).toEqual([{ slug: 'demo', action: 'installed' }])
expect(result.warnings).toEqual([{
slug: 'demo',
message: 'target lock cleanup failed: simulated release failure'
}])
expect(JSON.parse(renderPullResult(result, true, false)).warnings).toEqual(result.warnings)
expect(renderPullResult(result, false, false)).toContain('warning demo: target lock cleanup failed')
} finally {
registry.stop()
}
})
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
@ -87,6 +127,230 @@ describe('sync command', () => {
}
})
test('reports a newer remote version as update-available even when content is unchanged', 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 skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '1.0.0',
versionId: 1,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = '1.1.0'
skill.versionId = 2
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]).toMatchObject({
status: 'update-available',
localVersion: '1.0.0',
remoteVersion: '1.1.0'
})
} finally {
registry.stop()
}
})
test('blocks same-version remote content drift even with force', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const original = makeSkill('# original\n')
const changed = makeSkill('# changed without a version bump\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '1.0.0',
versionId: 1,
...original
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.fingerprint = changed.fingerprint
skill.zipBytes = changed.zipBytes
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]).toMatchObject({
status: 'blocked',
reason: 'remote content changed without a newer version; use explicit install after verifying the release'
})
const checked = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--check',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(checked.exitCode).toBe(6)
expect(JSON.parse(checked.stdout)).toMatchObject({ ok: false, check: true })
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# original\n')
} finally {
registry.stop()
}
})
test('blocks automatic downgrade even when remote content is unchanged', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('# stable content\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: '2.0.0',
versionId: 2,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = '1.0.0'
skill.versionId = 1
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: '2.0.0',
remoteVersion: '1.0.0',
reason: 'remote version is older than the installed version; local files were kept'
})
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# stable content\n')
} finally {
registry.stop()
}
})
test('blocks sync when local and remote versions cannot be ordered', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('# stable content\n')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: 'release-a',
versionId: 1,
...fixture
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skill.version = 'release-b'
skill.versionId = 2
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(6)
expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({
status: 'blocked',
localVersion: 'release-a',
remoteVersion: 'release-b',
reason: 'cannot determine version order; use explicit install after verifying the release'
})
} finally {
registry.stop()
}
})
test('hard remote guards cannot be bypassed by local changes and force', async () => {
const original = makeSkill('# original\n')
const variants = [
{
name: 'downgrade',
initialVersion: '2.0.0',
remoteVersion: '1.0.0',
remote: original,
reason: 'remote version is older than the installed version; local files were kept'
},
{
name: 'same-version drift',
initialVersion: '1.0.0',
remoteVersion: '1.0.0',
remote: makeSkill('# changed without a version bump\n'),
reason: 'remote content changed without a newer version; use explicit install after verifying the release'
},
{
name: 'unknown version order',
initialVersion: 'release-a',
remoteVersion: 'release-b',
remote: original,
reason: 'cannot determine version order; use explicit install after verifying the release'
}
]
for (const variant of variants) {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const skill: FakeSkill = {
namespace: 'team-a',
slug: 'demo',
version: variant.initialVersion,
versionId: 1,
...original
}
const registry = await startFakeRegistry({ token: 'token', skills: [skill] })
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 edit before ${variant.name}\n`)
skill.version = variant.remoteVersion
skill.versionId = 2
skill.fingerprint = variant.remote.fingerprint
skill.zipBytes = variant.remote.zipBytes
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
const output = JSON.parse(pulled.stdout)
expect(pulled.exitCode, variant.name).toBe(6)
expect(output.entries[0], variant.name).toMatchObject({ status: 'blocked', reason: variant.reason })
expect(output.entries[0].changedFiles, variant.name).toEqual(['SKILL.md'])
expect(output.actions, variant.name).toEqual([])
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8'))
.toBe(`# local edit before ${variant.name}\n`)
} finally {
registry.stop()
}
}
})
test('prune removes only unchanged managed orphan skills', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')

View file

@ -0,0 +1,727 @@
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { strToU8, zipSync } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { executeSkillUpgradePlan, planSkillUpgrades } from '../../src/services/upgrade-service'
import { installSkill } from '../../src/services/install-service'
import { renderUpgradeResult } from '../../src/commands/upgrade'
let registries: Array<Awaited<ReturnType<typeof startFakeRegistry>>> = []
afterEach(() => {
for (const registry of registries) registry.stop()
registries = []
})
function makeSkill(content: string): { fingerprint: string; zipBytes: Uint8Array } {
const bytes = strToU8(content)
const fileHash = createHash('sha256').update(bytes).digest('hex')
return {
fingerprint: `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`,
zipBytes: zipSync({ 'SKILL.md': bytes })
}
}
function makeSkillZip(content: string): Uint8Array {
return makeSkill(content).zipBytes
}
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
describe('upgrade command', () => {
test('check is side-effect free and execute upgrades an installed skill', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'skillhub-registry',
version: '1.0.0',
versionId: 1,
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
const installed = await runCli([
'install', '@global/skillhub-registry', '--dir', rootDir, '--registry', registry.url
], { HOME: env.home, USERPROFILE: env.home })
expect(installed.exitCode).toBe(0)
skill.version = '1.1.0'
skill.versionId = 2
Object.assign(skill, makeSkill('# v2'))
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
const metadataPath = join(rootDir, 'skillhub-registry', '.skillhub', 'metadata.json')
const inventoryBeforeCheck = await readFile(inventoryPath, 'utf-8')
const metadataBeforeCheck = await readFile(metadataPath, 'utf-8')
const checked = await runCli([
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(checked.exitCode).toBe(0)
expect(JSON.parse(checked.stdout).items[0]).toMatchObject({
coordinate: '@global/skillhub-registry',
currentVersion: '1.0.0',
remoteVersion: '1.1.0',
action: 'upgrade'
})
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v1')
expect(registry.received.downloads).toBe(1)
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck)
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck)
const checkedAgain = await runCli([
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(checkedAgain.exitCode).toBe(0)
expect(checkedAgain.stdout).toBe(checked.stdout)
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck)
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck)
expect(registry.received.downloads).toBe(1)
const upgraded = await runCli([
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(upgraded.exitCode).toBe(0)
expect(JSON.parse(upgraded.stdout).items[0].action).toBe('upgraded')
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v2')
expect(registry.received.downloads).toBe(2)
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
expect(metadata).toMatchObject({
schemaVersion: 1,
version: '1.1.0',
versionId: 2,
fingerprint: makeSkill('# v2').fingerprint
})
expect(Object.keys(metadata.files)).toContain('SKILL.md')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
})
test('local changes block by default and --force replaces only the same source', async () => {
const env = await createTempHome()
const skill = {
namespace: 'team',
slug: 'code-review',
version: '1.0.0',
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@team/code-review', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
await writeFile(join(rootDir, 'code-review', 'SKILL.md'), '# locally edited')
skill.version = '1.1.0'
Object.assign(skill, makeSkill('# v2'))
const blocked = await runCli([
'upgrade', '@team/code-review', '--registry', registry.url, '--agent', 'custom', '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(blocked.exitCode).toBe(6)
expect(JSON.parse(blocked.stdout).items[0]).toMatchObject({ action: 'blocked' })
expect(JSON.parse(blocked.stdout).items[0].reason).toContain('local changes')
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# locally edited')
const forced = await runCli([
'upgrade', '@team/code-review', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(forced.exitCode).toBe(0)
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# v2')
})
test('a local edit made after planning is rechecked before replacement', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'late-edit',
version: '1.0.0',
versionId: 1,
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/late-edit', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
}, { cwd: env.cwd })
skill.version = '1.1.0'
skill.versionId = 2
Object.assign(skill, makeSkill('# v2'))
const tokenForRegistry = async () => undefined
const plan = await planSkillUpgrades({
coordinates: ['@global/late-edit'],
registry: registry.url,
force: false,
home: env.home,
tokenForRegistry
})
await writeFile(join(rootDir, 'late-edit', 'SKILL.md'), '# edited after planning')
const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry })
expect(result.items[0]).toMatchObject({ action: 'failed' })
expect(result.items[0]?.reason).toContain('local changes detected after upgrade planning')
expect(await readFile(join(rootDir, 'late-edit', 'SKILL.md'), 'utf-8'))
.toBe('# edited after planning')
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint })
})
test('a target removed after planning is not recreated by upgrade', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'removed-late',
version: '1.0.0',
versionId: 1,
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/removed-late', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
}, { cwd: env.cwd })
skill.version = '1.1.0'
skill.versionId = 2
Object.assign(skill, makeSkill('# v2'))
const tokenForRegistry = async () => undefined
const plan = await planSkillUpgrades({
coordinates: ['@global/removed-late'],
registry: registry.url,
force: false,
home: env.home,
tokenForRegistry
})
const skillDir = join(rootDir, 'removed-late')
await rm(skillDir, { recursive: true })
const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry })
expect(result.items[0]).toMatchObject({ action: 'failed' })
expect(result.items[0]?.reason).toContain('installed target disappeared before upgrade commit')
expect(await exists(skillDir)).toBe(false)
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint })
})
test('new installs persist absolute targets and legacy relative targets are blocked safely', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'portable',
version: '1.0.0',
versionId: 1,
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const installed = await runCli([
'install', '@global/portable', '--dir', 'skills', '--registry', registry.url
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(installed.exitCode).toBe(0)
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(isAbsolute(inventory.items[0].targets[0].rootDir)).toBe(true)
expect(inventory.items[0].targets[0].installDir)
.toBe(join(inventory.items[0].targets[0].rootDir, 'portable'))
expect(await realpath(inventory.items[0].targets[0].rootDir))
.toBe(await realpath(join(env.cwd, 'skills')))
expect(await realpath(inventory.items[0].targets[0].installDir))
.toBe(await realpath(join(env.cwd, 'skills', 'portable')))
inventory.items[0].targets[0].rootDir = 'skills'
inventory.items[0].targets[0].installDir = join('skills', 'portable')
await writeFile(inventoryPath, JSON.stringify(inventory))
skill.version = '1.1.0'
skill.versionId = 2
Object.assign(skill, makeSkill('# v2'))
const otherCwd = join(env.cwd, 'other')
await mkdir(otherCwd, { recursive: true })
const result = await runCli([
'upgrade', '@global/portable', '--registry', registry.url, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home }, { cwd: otherCwd })
expect(result.exitCode).toBe(6)
expect(JSON.parse(result.stdout).items[0].reason).toContain('legacy relative target path')
expect(await readFile(join(env.cwd, 'skills', 'portable', 'SKILL.md'), 'utf-8')).toBe('# v1')
})
test('source conflict is a hard block even with --force', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/demo', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
const metadataPath = join(rootDir, 'demo', '.skillhub', 'metadata.json')
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
metadata.namespace = 'another-team'
await writeFile(metadataPath, JSON.stringify(metadata))
skill.version = '2.0.0'
skill.fingerprint = makeSkill('# v2').fingerprint
const result = await runCli([
'upgrade', '@global/demo', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).toBe(6)
expect(JSON.parse(result.stdout).items[0].reason).toContain('source-conflict')
expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# v1')
})
test('a bare slug must identify exactly one installed source', async () => {
const env = await createTempHome()
const skillA = { namespace: 'team-a', slug: 'demo', version: '1.0.0', ...makeSkill('# A') }
const skillB = { namespace: 'team-b', slug: 'demo', version: '1.0.0', ...makeSkill('# B') }
const registryA = await startFakeRegistry({ skills: [skillA] })
const registryB = await startFakeRegistry({ skills: [skillB] })
registries.push(registryA, registryB)
const rootA = join(env.cwd, 'a')
const rootB = join(env.cwd, 'b')
await mkdir(rootA, { recursive: true })
await mkdir(rootB, { recursive: true })
await runCli(['install', '@team-a/demo', '--dir', rootA, '--registry', registryA.url], {
HOME: env.home,
USERPROFILE: env.home
})
await runCli(['install', '@team-b/demo', '--dir', rootB, '--registry', registryB.url], {
HOME: env.home,
USERPROFILE: env.home
})
const ambiguous = await runCli(['upgrade', 'demo', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(ambiguous.exitCode).toBe(5)
expect(ambiguous.stderr).toContain('ambiguous')
const selected = await runCli(['upgrade', 'demo', '--namespace', 'team-a', '--registry', registryA.url, '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(selected.exitCode).toBe(0)
expect(selected.stdout).toContain('@team-a/demo')
const fullCoordinate = await runCli(['upgrade', '@team-a/demo', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(fullCoordinate.exitCode).toBe(0)
expect(fullCoordinate.stdout).toContain('@team-a/demo')
expect(fullCoordinate.stdout).not.toContain('@team-b/demo')
const noNamespaceMatch = await runCli(['upgrade', 'demo', '--namespace', 'missing', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(noNamespaceMatch.exitCode).toBe(5)
expect(noNamespaceMatch.stderr).toContain('not installed')
})
test('target filters select deterministically and missing matches never install', async () => {
const env = await createTempHome()
const skill = { namespace: 'global', slug: 'filtered', version: '1.0.0', ...makeSkill('# v1') }
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/filtered', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
for (const args of [
['--dir', rootDir],
['--agent', 'custom'],
['--namespace', 'global'],
['--registry', registry.url]
]) {
const result = await runCli(['upgrade', 'filtered', ...args, '--force', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
}
for (const args of [
['--dir', join(env.cwd, 'missing')],
['--agent', 'codex'],
['--namespace', 'missing'],
['--registry', 'http://unmatched.invalid']
]) {
const result = await runCli(['upgrade', 'filtered', ...args, '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('not installed')
}
expect(registry.received.downloads).toBe(1)
})
test('one resolved archive is reused for every managed target', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'shared',
version: '1.0.0',
...makeSkill('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const installed = await runCli([
'install', '@global/shared', '--agent', 'codex', '--agent', 'claude-code', '--registry', registry.url
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(installed.exitCode).toBe(0)
expect(registry.received.resolves).toBe(1)
expect(registry.received.downloads).toBe(1)
skill.version = '1.1.0'
Object.assign(skill, makeSkill('# v2'))
const partial = await runCli([
'upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(partial.exitCode).toBe(6)
expect(JSON.parse(partial.stdout).items[0].reason).toContain('partial-target')
expect(registry.received.downloads).toBe(1)
const upgraded = await runCli(['upgrade', '@global/shared', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
}, { cwd: env.cwd })
expect(upgraded.exitCode).toBe(0)
expect(registry.received.resolves).toBe(2)
expect(registry.received.downloads).toBe(2)
expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2')
expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2')
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
expect(inventory.items[0].targets).toHaveLength(2)
const listed = await runCli(['list', '--json', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(listed.exitCode).toBe(0)
expect(JSON.parse(listed.stdout).items[0]).toMatchObject({ version: '1.1.0' })
})
test('never downgrades when the registry latest version moves backwards', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'stable',
version: '2.0.0',
versionId: 2,
...makeSkill('# v2')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/stable', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
skill.version = '1.0.0'
skill.versionId = 1
Object.assign(skill, makeSkill('# v1'))
const result = await runCli([
'upgrade', '@global/stable', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).toBe(6)
expect(JSON.parse(result.stdout).items[0].reason).toContain('older')
expect(await readFile(join(rootDir, 'stable', 'SKILL.md'), 'utf-8')).toBe('# v2')
expect(registry.received.downloads).toBe(1)
})
test('keeps local files when resolve is unavailable or same-version content drifts', async () => {
const env = await createTempHome()
const failures: { resolve?: 'server_error' } = {}
const skill = { namespace: 'global', slug: 'resilient', version: '1.0.0', ...makeSkill('# v1') }
const registry = await startFakeRegistry({ skills: [skill], failures })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/resilient', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
failures.resolve = 'server_error'
const unavailable = await runCli([
'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(unavailable.exitCode).toBe(6)
expect(JSON.parse(unavailable.stdout).items[0].action).toBe('blocked')
expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1')
delete failures.resolve
Object.assign(skill, makeSkill('# changed without version bump'))
const drifted = await runCli([
'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(drifted.exitCode).toBe(6)
expect(JSON.parse(drifted.stdout).items[0].reason).toContain('without a newer version')
expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1')
expect(registry.received.downloads).toBe(1)
})
test('a blocked batch reports a plan and does not claim successful writes', async () => {
const env = await createTempHome()
const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') }
const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') }
const registry = await startFakeRegistry({ skills: [first, second] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
for (const slug of ['first', 'second']) {
await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
}
first.version = '1.1.0'
Object.assign(first, makeSkill('# first v2'))
second.version = '1.1.0'
Object.assign(second, makeSkill('# second v2'))
await writeFile(join(rootDir, 'second', 'SKILL.md'), '# local change')
const result = await runCli([
'upgrade', '@global/first', '@global/second', '--registry', registry.url, '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).toBe(6)
const output = JSON.parse(result.stdout)
expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/first')).action).toBe('upgrade')
expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/second')).action).toBe('blocked')
expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v1')
})
test('a runtime batch failure reports committed, failed, and unattempted skills', async () => {
const env = await createTempHome()
const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') }
const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') }
const third = { namespace: 'global', slug: 'third', version: '1.0.0', ...makeSkill('# third v1') }
const registry = await startFakeRegistry({ skills: [first, second, third] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
for (const slug of ['first', 'second', 'third']) {
await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
}
first.version = '1.1.0'
Object.assign(first, makeSkill('# first v2'))
second.version = '1.1.0'
second.fingerprint = makeSkill('# second v2').fingerprint
second.zipBytes = strToU8('not a zip archive')
third.version = '1.1.0'
Object.assign(third, makeSkill('# third v2'))
const result = await runCli([
'upgrade', '@global/first', '@global/second', '@global/third',
'--registry', registry.url, '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).toBe(1)
const output = JSON.parse(result.stdout)
expect(output.summary).toEqual({ upgraded: 1, unchanged: 0, failed: 1, notAttempted: 1 })
expect(output.items.map((item: { action: string }) => item.action))
.toEqual(['upgraded', 'failed', 'not-attempted'])
expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v2')
expect(await readFile(join(rootDir, 'second', 'SKILL.md'), 'utf-8')).toBe('# second v1')
expect(await readFile(join(rootDir, 'third', 'SKILL.md'), 'utf-8')).toBe('# third v1')
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items.find((item: { slug: string }) => item.slug === 'first').version).toBe('1.1.0')
expect(inventory.items.find((item: { slug: string }) => item.slug === 'second').version).toBe('1.0.0')
expect(inventory.items.find((item: { slug: string }) => item.slug === 'third').version).toBe('1.0.0')
expect(registry.received.downloads).toBe(5)
})
test('a committed upgrade keeps success and renders a post-commit warning', async () => {
const env = await createTempHome()
const skill = { namespace: 'global', slug: 'warned', version: '1.0.0', ...makeSkill('# v1') }
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/warned', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
skill.version = '1.1.0'
Object.assign(skill, makeSkill('# v2'))
const tokenForRegistry = async () => undefined
const plan = await planSkillUpgrades({
coordinates: ['@global/warned'],
registry: registry.url,
force: false,
home: env.home,
tokenForRegistry
})
const result = await executeSkillUpgradePlan(plan, {
home: env.home,
tokenForRegistry,
installSkillFn: options => installSkill({
...options,
acquireTargetLock: async () => async () => { throw new Error('simulated release failure') }
})
})
expect(result).toMatchObject({ upgraded: 1, failed: 0 })
expect(result.items[0]).toMatchObject({ action: 'upgraded' })
expect(result.items[0]?.warnings).toEqual(['target lock cleanup failed: simulated release failure'])
expect(JSON.parse(renderUpgradeResult(plan, result, true)).items[0].warnings).toHaveLength(1)
expect(renderUpgradeResult(plan, result, false)).toContain('upgraded')
expect(renderUpgradeResult(plan, result, false)).toContain('[warning: target lock cleanup failed')
expect(await readFile(join(rootDir, 'warned', 'SKILL.md'), 'utf-8')).toBe('# v2')
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint })
})
test('legacy metadata without a file baseline requires explicit force migration', async () => {
const env = await createTempHome()
const skill = { namespace: 'global', slug: 'legacy', version: '1.0.0', ...makeSkill('# v1') }
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
await runCli(['install', '@global/legacy', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
const metadataPath = join(rootDir, 'legacy', '.skillhub', 'metadata.json')
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
delete metadata.files
delete metadata.schemaVersion
await writeFile(metadataPath, JSON.stringify(metadata))
skill.version = '1.1.0'
Object.assign(skill, makeSkill('# v2'))
const blocked = await runCli([
'upgrade', '@global/legacy', '--registry', registry.url, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(blocked.exitCode).toBe(6)
expect(JSON.parse(blocked.stdout).items[0].reason).toContain('no file baseline')
const migrated = await runCli([
'upgrade', '@global/legacy', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(migrated.exitCode).toBe(0)
expect(await readFile(join(rootDir, 'legacy', 'SKILL.md'), 'utf-8')).toBe('# v2')
const migratedMetadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
expect(migratedMetadata.schemaVersion).toBe(1)
expect(Object.keys(migratedMetadata.files)).toContain('SKILL.md')
})
test('never installs a missing skill and never offers an implicit upgrade-all', async () => {
const env = await createTempHome()
const missing = await runCli(['upgrade', '@global/missing', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(missing.exitCode).toBe(5)
expect(missing.stderr).toContain('use skillhub install')
const empty = await runCli(['upgrade'], { HOME: env.home, USERPROFILE: env.home })
expect(empty.exitCode).toBe(5)
expect(empty.stderr).toContain('at least one')
const tooMany = await runCli([
'upgrade', ...Array.from({ length: 51 }, (_, index) => `@global/skill-${index}`)
], { HOME: env.home, USERPROFILE: env.home })
expect(tooMany.exitCode).toBe(5)
expect(tooMany.stderr).toContain('at most 50')
})
test('accepts exactly fifty explicitly installed coordinates', async () => {
const env = await createTempHome()
const skills = Array.from({ length: 50 }, (_, index) => ({
namespace: 'global',
slug: `skill-${index}`,
version: '1.0.0',
fingerprint: `fp-${index}`,
zipBytes: makeSkillZip(`# skill ${index}`)
}))
const registry = await startFakeRegistry({ skills })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
const items = []
for (const skill of skills) {
const skillDir = join(rootDir, skill.slug)
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `# ${skill.slug}`)
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({
registry: registry.url,
namespace: skill.namespace,
slug: skill.slug,
version: skill.version,
fingerprint: skill.fingerprint,
source: 'skillhub'
}))
items.push({
registry: registry.url,
namespace: skill.namespace,
slug: skill.slug,
version: skill.version,
fingerprint: skill.fingerprint,
targets: [{
agent: 'custom', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z'
}]
})
}
await mkdir(join(env.home, '.skillhub'), { recursive: true })
await writeFile(join(env.home, '.skillhub', 'inventory.json'), JSON.stringify({ items }))
const result = await runCli([
'upgrade', ...skills.map(skill => `@global/${skill.slug}`),
'--registry', registry.url, '--force', '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout).summary).toMatchObject({ unchanged: 50, blocked: 0 })
expect(registry.received.downloads).toBe(0)
})
})

View file

@ -37,23 +37,25 @@ describe('version upgrade flow', () => {
// -------------------------------------------------------------------------
// VU1 — full upgrade lifecycle:
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
// 2. Stop registry, start a new one serving pdf-parser@2.0.0
// 3. Install --force using the new registry URL
// 2. The same registry serves pdf-parser@2.0.0
// 3. Install --force from the same source identity
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
// -------------------------------------------------------------------------
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
const env = await createTempHome()
// --- Stage 1: install v1 ----------------------------------------------
const skill = {
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
versionId: 1,
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
}
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
}]
skills: [skill]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
@ -77,22 +79,10 @@ describe('version upgrade flow', () => {
expect(body).toContain('Version one body.')
}
// --- Stage 2: swap registry to v2 -------------------------------------
registry.stop()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '2.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
}]
})
// Re-login against the new registry (URL changed, so credentials are
// keyed differently).
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// --- Stage 2: publish v2 from the same source --------------------------
skill.version = '2.0.0'
skill.versionId = 2
skill.zipBytes = makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],

View file

@ -146,6 +146,25 @@ describe('installCommand dependency injection', () => {
}] as AgentCandidate[]
}
test('renders post-commit warnings in human and JSON output', async () => {
const deps: InstallCommandDeps = {
isTTY: () => false,
resolveInstallTargets: fakeResolveInstallTargets(),
installSkill: async () => ({
installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }],
warnings: ['target lock cleanup failed: simulated release failure']
})
}
const human = await installCommand('@global/foo', { registry: 'http://localhost' }, deps)
expect(human).toContain('Warning: target lock cleanup failed')
const json = JSON.parse(await installCommand('@global/foo', {
registry: 'http://localhost',
json: true
}, deps))
expect(json.warnings).toEqual(['target lock cleanup failed: simulated release failure'])
})
test('passes a namespaced coordinate to installSkill', async () => {
let received: Parameters<NonNullable<InstallCommandDeps['installSkill']>>[0] | undefined
const deps: InstallCommandDeps = {

View file

@ -1,9 +1,14 @@
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync } from 'fflate'
import { installSkill } from '../../../src/services/install-service'
import { planSkillUpgrades } from '../../../src/services/upgrade-service'
import { removeLocalSkill } from '../../../src/services/remove-service'
import { skillTargetLockPath } from '../../../src/services/skill-target-lock'
import { EXIT } from '../../../src/shared/constants'
const originalFetch = globalThis.fetch
@ -16,7 +21,16 @@ async function exists(path: string): Promise<boolean> {
}
}
function installFetch(zipEntries: Record<string, string>): typeof fetch {
function skillFingerprint(zipEntries: Record<string, string>): string {
const aggregate = createHash('sha256')
for (const [name, content] of Object.entries(zipEntries).sort(([left], [right]) => left.localeCompare(right))) {
const fileHash = createHash('sha256').update(content).digest('hex')
aggregate.update(`${name}:${fileHash}\n`, 'utf8')
}
return `sha256:${aggregate.digest('hex')}`
}
function installFetch(zipEntries: Record<string, string>, fingerprint = skillFingerprint(zipEntries)): typeof fetch {
const archive = zipSync(Object.fromEntries(
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
@ -24,10 +38,10 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
))
), fingerprint)
}
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
function installFetchWithDownloadResponse(downloadResponse: Response, fingerprint = 'fp'): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
@ -38,7 +52,7 @@ function installFetchWithDownloadResponse(downloadResponse: Response): typeof fe
slug: 'demo',
version: '1.0.0',
versionId: 1,
fingerprint: 'fp',
fingerprint,
downloadUrl: '/download'
}
})
@ -51,6 +65,22 @@ function installFetchWithDownloadResponse(downloadResponse: Response): typeof fe
return fakeFetch as unknown as typeof fetch
}
async function writeManagedMetadata(
skillDir: string,
identity: { registry: string; namespace: string; slug: string } = {
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo'
}
): Promise<void> {
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({
...identity,
version: '0.1.0',
source: 'skillhub'
}))
}
describe('installSkill', () => {
afterEach(() => {
globalThis.fetch = originalFetch
@ -97,6 +127,49 @@ describe('installSkill', () => {
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rejects a downloaded fingerprint mismatch before replacing files or inventory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Unexpected' }, 'sha256:unexpected')
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Existing')
await writeManagedMetadata(skillDir)
await mkdir(join(home, '.skillhub'), { recursive: true })
const inventoryBefore = JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-09-01T00:00:00Z'
}]
}]
})
await writeFile(inventoryPath, inventoryBefore)
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toMatchObject({
exitCode: EXIT.validation,
message: 'downloaded skill fingerprint does not match the resolved release'
})
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Existing')
expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBefore)
expect((await readdir(rootDir)).some(name => name.includes('.install-'))).toBe(false)
})
test('rejects canonical target aliases before writing any installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
@ -128,6 +201,70 @@ describe('installSkill', () => {
}
})
test('migrates a canonical inventory target to its selected path alias without duplication', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Reinstalled' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-alias-'))
const realRoot = join(parent, 'real')
const aliasRoot = join(parent, 'alias')
const realSkillDir = join(realRoot, 'demo')
const aliasSkillDir = join(aliasRoot, 'demo')
await mkdir(realSkillDir, { recursive: true })
await writeFile(join(realSkillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(realSkillDir)
await symlink(realRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir: realRoot,
installDir: realSkillDir,
installedAt: '2026-09-01T00:00:00Z'
}]
}]
}))
await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir: aliasRoot, scope: 'project', source: 'explicit' }],
force: true,
home
})
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0].targets).toEqual([
expect.objectContaining({ rootDir: aliasRoot, installDir: aliasSkillDir })
])
const plan = await planSkillUpgrades({
coordinates: ['@global/demo'],
registry: 'http://registry.test',
force: false,
home,
tokenForRegistry: async () => undefined
})
expect(plan).toMatchObject({ blocked: 0, unchanged: 1 })
await removeLocalSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
home
})
expect(await exists(realSkillDir)).toBe(false)
expect((JSON.parse(await readFile(inventoryPath, 'utf-8'))).items).toEqual([])
})
test('force replaces the old skill directory instead of overlaying files', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
@ -135,6 +272,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'stale.txt'), 'old')
await writeManagedMetadata(skillDir)
await installSkill({
registry: 'http://registry.test',
@ -149,12 +287,39 @@ describe('installSkill', () => {
expect(await exists(join(skillDir, 'stale.txt'))).toBe(false)
})
test('force removes stale inventory records that point at the replaced install directory', async () => {
test('reports lock cleanup failure as a warning after committing the installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Committed' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
const result = await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
acquireTargetLock: async () => async () => { throw new Error('simulated release failure') }
})
expect(result.warnings).toEqual(['target lock cleanup failed: simulated release failure'])
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Committed')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({
version: '1.0.0',
fingerprint: skillFingerprint({ 'SKILL.md': '# Committed' })
})
})
test('force rejects a different namespace at the same install directory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Team Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
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'), '# Global Demo')
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
@ -172,20 +337,241 @@ describe('installSkill', () => {
}]
}))
await installSkill({
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'team',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})
})).rejects.toThrow('source conflict')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'team', slug: 'demo' })
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo' })
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Global Demo')
})
test('force rejects metadata explicitly owned by another installer', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
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'), '# Manual')
await writeManagedMetadata(skillDir)
const metadataPath = join(skillDir, '.skillhub', 'metadata.json')
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
metadata.source = 'manual'
await writeFile(metadataPath, JSON.stringify(metadata))
const metadataBefore = await readFile(metadataPath, 'utf-8')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('cannot verify SkillHub ownership')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Manual')
expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBefore)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
const entries = await readdir(rootDir)
expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false)
expect(entries.some(name => name.includes('skillhub-install.lock'))).toBe(false)
})
test('force rejects a directory without installation metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'local.txt'), 'keep')
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true, home
})).rejects.toThrow('cannot verify SkillHub ownership')
expect(await readFile(join(skillDir, 'local.txt'), 'utf-8')).toBe('keep')
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('force rejects a different slug in installation metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
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'), '# Other')
await writeManagedMetadata(skillDir, {
registry: 'http://registry.test', namespace: 'global', slug: 'other'
})
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true, home
})).rejects.toThrow('source conflict')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Other')
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('revalidates ownership after download before replacing the target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
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')
await writeManagedMetadata(skillDir)
const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') })
globalThis.fetch = (async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({
code: 0,
data: {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
versionId: 1,
fingerprint: skillFingerprint({ 'SKILL.md': '# New' }),
downloadUrl: '/download'
}
})
}
if (path.endsWith('/download')) {
await writeManagedMetadata(skillDir, {
registry: 'http://other-registry.test',
namespace: 'global',
slug: 'demo'
})
await writeFile(join(skillDir, 'SKILL.md'), '# Replaced during download')
return new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
)
}
return Response.json({ code: 404 }, { status: 404 })
}) as typeof fetch
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('source conflict')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Replaced during download')
const metadata = JSON.parse(await readFile(join(skillDir, '.skillhub', 'metadata.json'), 'utf-8'))
expect(metadata.registry).toBe('http://other-registry.test')
expect((await readdir(rootDir)).some(name => name.includes('skillhub-backup'))).toBe(false)
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rolls back every target when a later target changes source before commit', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const firstRoot = join(parent, 'a')
const secondRoot = join(parent, 'b')
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
for (const skillDir of [firstSkillDir, secondSkillDir]) {
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `# Old ${skillDir === firstSkillDir ? 'A' : 'B'}`)
await writeManagedMetadata(skillDir)
}
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [
{ agent: 'codex', rootDir: firstRoot, installDir: firstSkillDir, installedAt: '2026-09-01T00:00:00Z' },
{ agent: 'claude-code', rootDir: secondRoot, installDir: secondSkillDir, installedAt: '2026-09-01T00:00:00Z' }
]
}]
}))
const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') })
globalThis.fetch = (async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({ code: 0, data: {
namespace: 'global', slug: 'demo', version: '1.0.0', versionId: 1,
fingerprint: skillFingerprint({ 'SKILL.md': '# New' }), downloadUrl: '/download'
} })
}
if (path.endsWith('/download')) {
await writeManagedMetadata(secondSkillDir, {
registry: 'http://other-registry.test', namespace: 'global', slug: 'demo'
})
return new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
)
}
return Response.json({ code: 404 }, { status: 404 })
}) as typeof fetch
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' }
],
force: true,
home
})).rejects.toThrow('source conflict')
expect(await readFile(join(firstSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old A')
expect(await readFile(join(secondSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old B')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8'))
expect(inventory.items[0]).toMatchObject({ version: '0.1.0' })
expect(inventory.items[0].targets).toHaveLength(2)
for (const rootDir of [firstRoot, secondRoot]) {
const entries = await readdir(rootDir)
expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false)
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
}
})
test('rejects an active target lock and recovers a dead-process lock', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
await expect(installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false, home
})).rejects.toThrow('install target is busy')
expect(await exists(join(rootDir, 'demo'))).toBe(false)
await rm(lockPath, { recursive: true })
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
await installSkill({
registry: 'http://registry.test', namespace: 'global', slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false, home
})
expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# New')
expect(await exists(lockPath)).toBe(false)
})
test('force keeps old installation and inventory when replacement extraction fails', async () => {
@ -195,6 +581,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
@ -234,6 +621,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const invalidHome = join(rootDir, 'home-is-a-file')
await writeFile(invalidHome, 'not a directory')

View file

@ -1,8 +1,9 @@
import { access, mkdir, mkdtemp } from 'node:fs/promises'
import { access, mkdir, mkdtemp, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { removeLocalSkill } from '../../../src/services/remove-service'
import { acquireSkillTargetLock, skillTargetLockPath } from '../../../src/services/skill-target-lock'
import { InventoryStore } from '../../../src/stores/inventory-store'
async function exists(path: string): Promise<boolean> {
@ -92,6 +93,98 @@ describe('removeLocalSkill', () => {
expect((await store.read()).items.map(item => item.namespace)).toEqual(['global'])
})
test('does not remove a target while install or upgrade holds its lifecycle lock', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-lock-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-remove-lock-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
const store = new InventoryStore(home)
await store.write({
items: [{
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'demo',
version: '1.0.0',
targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }]
}]
})
const release = await acquireSkillTargetLock(rootDir, 'demo')
try {
await expect(removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home }))
.rejects.toThrow('install target is busy')
expect(await exists(skillDir)).toBe(true)
expect((await store.read()).items[0]?.targets).toHaveLength(1)
} finally {
await release()
}
const removed = await removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home })
expect(removed.removed).toHaveLength(1)
expect(await exists(skillDir)).toBe(false)
expect((await store.read()).items).toEqual([])
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
})
test('legacy symlink roots use the same lifecycle lock as their real target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-alias-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-remove-alias-parent-'))
const realRoot = join(parent, 'real-root')
const aliasRoot = join(parent, 'legacy-alias')
const realSkillDir = join(realRoot, 'demo')
const aliasSkillDir = join(aliasRoot, 'demo')
await mkdir(realSkillDir, { recursive: true })
await symlink(realRoot, aliasRoot, 'dir')
const store = new InventoryStore(home)
await store.write({
items: [{
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'demo',
version: '1.0.0',
targets: [{ agent: 'codex', rootDir: aliasRoot, installDir: aliasSkillDir, installedAt: '2026-09-01T00:00:00Z' }]
}]
})
const lockPath = await skillTargetLockPath(realRoot, 'demo')
expect(await skillTargetLockPath(aliasRoot, 'demo')).toBe(lockPath)
const release = await acquireSkillTargetLock(realRoot, 'demo')
try {
await expect(removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home }))
.rejects.toThrow('install target is busy')
expect(await exists(realSkillDir)).toBe(true)
expect((await store.read()).items[0]?.targets).toHaveLength(1)
} finally {
await release()
}
expect(await exists(lockPath)).toBe(false)
})
test('removes a stale inventory target when the recorded root directory is missing', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-stale-home-'))
const parent = await mkdtemp(join(tmpdir(), 'skillhub-remove-stale-parent-'))
const rootDir = join(parent, 'missing-root')
const skillDir = join(rootDir, 'demo')
const store = new InventoryStore(home)
await store.write({
items: [{
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'demo',
version: '1.0.0',
targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }]
}]
})
const result = await removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home })
expect(result.removed).toEqual([{ namespace: 'global', agent: 'codex', dir: skillDir, existed: false }])
expect(await exists(rootDir)).toBe(false)
expect((await store.read()).items).toEqual([])
expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false)
})
test('throws on path traversal in installDir', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-traversal-'))

View file

@ -0,0 +1,134 @@
import { access, chmod, lstat, mkdir, mkdtemp, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, test } from 'bun:test'
import {
acquireSkillTargetLock,
assertPrivateLockDir,
ensurePrivateLockDir,
skillTargetLockPath
} from '../../../src/services/skill-target-lock'
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
async function waitForFile(path: string): Promise<void> {
for (let attempt = 0; attempt < 500; attempt++) {
if (await exists(path)) return
await Bun.sleep(10)
}
throw new Error(`timed out waiting for ${path}`)
}
describe('skill target lifecycle lock', () => {
test('creates or repairs a private lock root and rejects unsafe roots', async () => {
const parent = await mkdtemp(join(tmpdir(), 'skillhub-lock-root-'))
const privateRoot = join(parent, 'private')
await ensurePrivateLockDir(privateRoot)
if (process.platform !== 'win32') {
expect((await lstat(privateRoot)).mode & 0o077).toBe(0)
await chmod(privateRoot, 0o755)
await ensurePrivateLockDir(privateRoot)
expect((await lstat(privateRoot)).mode & 0o077).toBe(0)
}
const fileRoot = join(parent, 'file')
await writeFile(fileRoot, 'keep')
await expect(ensurePrivateLockDir(fileRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory')
expect(await Bun.file(fileRoot).text()).toBe('keep')
const symlinkTarget = join(parent, 'symlink-target')
const symlinkRoot = join(parent, 'symlink')
await mkdir(symlinkTarget)
await symlink(symlinkTarget, symlinkRoot, 'dir')
await expect(ensurePrivateLockDir(symlinkRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory')
expect((await lstat(symlinkRoot)).isSymbolicLink()).toBe(true)
expect(() => assertPrivateLockDir('/foreign', {
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 2000,
mode: 0o40700
}, 1000)).toThrow('owned by another user')
})
test('simultaneous stale recovery admits exactly one owner across processes', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-lock-root-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const worker = fileURLToPath(new URL('../../helpers/target-lock-worker.ts', import.meta.url))
const bunPath = (await Bun.which('bun')) ?? process.execPath
const acquiredPath = join(rootDir, 'acquired')
const releasePath = join(rootDir, 'release')
const startPath = join(rootDir, 'start')
const readyPaths = [join(rootDir, 'ready-0'), join(rootDir, 'ready-1')]
const processes = readyPaths.map(readyPath => Bun.spawn({
cmd: [bunPath, worker, rootDir, 'demo', readyPath, startPath, acquiredPath, releasePath],
stdout: 'pipe',
stderr: 'pipe'
}))
try {
await Promise.all(readyPaths.map(waitForFile))
await writeFile(startPath, 'start')
await waitForFile(acquiredPath)
const loserExitCode = await Promise.race([
...processes.map(process => process.exited),
Bun.sleep(5_000).then(() => { throw new Error('timed out waiting for the lock loser') })
])
expect(loserExitCode).toBe(4)
} finally {
try {
await writeFile(releasePath, 'release')
} finally {
const exited = await Promise.race([
Promise.all(processes.map(process => process.exited)).then(() => true),
Bun.sleep(5_000).then(() => false)
])
if (!exited) {
for (const process of processes) process.kill()
await Promise.all(processes.map(process => process.exited))
}
}
}
const results = await Promise.all(processes.map(async process => ({
exitCode: await process.exited,
stdout: (await new Response(process.stdout).text()).trim(),
stderr: (await new Response(process.stderr).text()).trim()
})))
expect(results.map(result => result.exitCode).sort()).toEqual([0, 4])
expect(results.filter(result => result.stdout === 'acquired')).toHaveLength(1)
expect(await exists(lockPath)).toBe(false)
if (process.platform !== 'win32') {
expect((await lstat(dirname(lockPath))).mode & 0o077).toBe(0)
}
}, 15_000)
test('keeps one lock identity when a symlink target is removed', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-root-'))
const linkedDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-value-'))
const skillDir = join(rootDir, 'demo')
await symlink(linkedDir, skillDir, process.platform === 'win32' ? 'junction' : 'dir')
const lockPathBeforeRemoval = await skillTargetLockPath(rootDir, 'demo')
const release = await acquireSkillTargetLock(rootDir, 'demo')
try {
await unlink(skillDir)
expect(await skillTargetLockPath(rootDir, 'demo')).toBe(lockPathBeforeRemoval)
await expect(acquireSkillTargetLock(rootDir, 'demo')).rejects.toThrow('install target is busy')
} finally {
await release()
}
expect(await exists(lockPathBeforeRemoval)).toBe(false)
})
})

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, utimes } from 'node:fs/promises'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
@ -37,19 +37,16 @@ describe('InventoryStore', () => {
expect(inventory.items).toHaveLength(5)
})
test('recovers from stale lock file', async () => {
test('recovers from a stale lock directory', async () => {
const home = await makeTempHome()
const store = new InventoryStore(home)
// Ensure the directory for the lock file exists
// proper-lockfile uses atomic mkdir and an mtime lease.
await mkdir(dirname(store.path), { recursive: true })
// Write a stale lock: dead PID + old timestamp
const lockPath = `${store.path}.lock`
await writeFile(
lockPath,
JSON.stringify({ pid: 999999, timestamp: Date.now() - 60000 }),
)
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
// upsertTarget should recover from the stale lock and succeed
await store.upsertTarget(
@ -98,4 +95,33 @@ describe('InventoryStore', () => {
const inventory = await store.read()
expect(inventory.items).toHaveLength(1)
})
test('rejects a version change while an unselected target is retained', async () => {
const home = await makeTempHome()
const store = new InventoryStore(home)
const retained = makeTarget('shared-a')
await store.upsertTarget(
'https://skill.xfyun.cn',
'global',
'shared',
'1.0.0',
retained,
'fp-v1',
)
const replacement = makeTarget('shared-b')
await expect(store.replaceTargetsAtInstallDirs(
'https://skill.xfyun.cn',
'global',
'shared',
'1.1.0',
[replacement],
'fp-v2',
)).rejects.toThrow('partial-target install would create inconsistent versions')
const inventory = await store.read()
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' })
expect(inventory.items[0]?.targets).toEqual([retained])
})
})

View file

@ -6,6 +6,8 @@ services:
SKILL_SCANNER_LLM_API_KEY: ${SKILL_SCANNER_LLM_API_KEY:-}
SKILL_SCANNER_LLM_BASE_URL: ${SKILL_SCANNER_LLM_BASE_URL:-}
SKILL_SCANNER_LLM_MODEL: ${SKILL_SCANNER_LLM_MODEL:-}
SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS: ${SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS:-1}
SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS: ${SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS:-930}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"]
interval: 10s
@ -87,6 +89,10 @@ services:
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_SECURITY_SCANNER_CONNECT_TIMEOUT: ${SKILLHUB_SECURITY_SCANNER_CONNECT_TIMEOUT:-5000}
SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:-900000}
SKILLHUB_SECURITY_SCANNER_RETRY_MAX: ${SKILLHUB_SECURITY_SCANNER_RETRY_MAX:-3}
SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:-PT16M}
SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true}
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}

View file

@ -160,6 +160,16 @@ spec:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-mode
- name: SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-read-timeout
- name: SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE
valueFrom:
configMapKeyRef:
name: skillhub-config
key: skill-scan-reclaim-min-idle
# Session
- name: SESSION_COOKIE_SECURE

View file

@ -35,6 +35,10 @@ data:
skill-scanner-enabled: "true"
skill-scanner-url: http://skillhub-scanner:8000
skill-scanner-mode: upload
skill-scanner-read-timeout: "900000"
skill-scan-reclaim-min-idle: PT16M
skill-scanner-max-concurrent-scans: "1"
skill-scanner-hard-timeout-seconds: "930"
# Bootstrap 管理员配置(非敏感)
bootstrap-admin-enabled: "true"

View file

@ -40,6 +40,16 @@ spec:
name: skillhub-secret
key: skill-scanner-llm-model
optional: true
- name: SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS
valueFrom:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-max-concurrent-scans
- name: SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS
valueFrom:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-hard-timeout-seconds
readinessProbe:
httpGet:
path: /health

View file

@ -230,10 +230,18 @@
| skill_id | bigint | |
| user_id | varchar(128) | |
| score | tinyint | 1-5 |
| review_text | varchar(2000) | 可选文字评价;空值表示仅评分 |
| review_status | enum | `VISIBLE` / `HIDDEN`,隐藏不影响评分聚合 |
| moderated_by | varchar(128) | 最近一次管理操作人nullable |
| moderated_at | datetime | 最近一次管理时间nullable |
| moderation_reason | varchar(500) | 隐藏原因nullable |
| lock_version | bigint | 乐观锁版本;并发编辑或治理冲突返回 409 |
| created_at | datetime | |
| updated_at | datetime | |
唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改
唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改。删除文字评价只清空
`review_text`,保留评分和既有治理状态;管理员隐藏评价时也保留评分,避免作者通过
清空后重新提交绕过治理,或治理动作改变聚合分数。
### user_account

View file

@ -252,6 +252,10 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确
→ 异步重算 skill.rating_avg 和 rating_countSELECT AVG + Redis 分布式锁防重复重算)
```
文字评价复用同一条 `skill_rating` 记录:用户提交 `score + review_text` 时同步更新评分并触发
`SkillRatedEvent`;删除评价只清空文字,保留星级评分。公开列表仅返回 `VISIBLE` 评价;
`SKILL_ADMIN` / `SUPER_ADMIN` 可隐藏或恢复评价,管理动作写入审计日志且不改变评分聚合。
## 7 异步事件汇总
| 事件 | 触发时机 | 消费方 |

View file

@ -82,6 +82,7 @@
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files` | 按标签查看文件清单 |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=...` | 按标签读取单个文件 |
| GET | `/api/v1/skills/{skillId}/reviews` | 公开评价分页列表;仅返回可见评价,管理员可见隐藏项 |
| GET | `/api/v1/namespaces` | 公开命名空间列表 |
| GET | `/api/v1/namespaces/{slug}` | 命名空间详情 |
@ -201,6 +202,9 @@ Public API 的可见性规则:
| POST | `/api/v1/skills/{namespace}/{slug}/star` | 收藏 |
| DELETE | `/api/v1/skills/{namespace}/{slug}/star` | 取消收藏 |
| POST | `/api/v1/skills/{namespace}/{slug}/rating` | 评分 |
| GET | `/api/v1/skills/{skillId}/reviews/me` | 当前用户的评分与文字评价 |
| PUT | `/api/v1/skills/{skillId}/reviews/me` | 新增或更新当前用户评价(`score` 1-5`reviewText` 最长 2000 |
| DELETE | `/api/v1/skills/{skillId}/reviews/me` | 删除文字评价并保留星级评分 |
| GET | `/api/v1/me/stars` | 我的收藏列表 |
| GET | `/api/v1/me/skills` | 我发布的技能列表 |
@ -312,6 +316,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
| POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能(仅 `SUPER_ADMIN` |
| POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能(仅 `SUPER_ADMIN` |
| POST | `/api/v1/admin/skills/versions/{versionId}/yank` | 撤回已发布版本(`SKILL_ADMIN` / `SUPER_ADMIN` |
| POST | `/api/v1/admin/skill-reviews/{reviewId}/hide` | 隐藏用户评价(`SKILL_ADMIN` / `SUPER_ADMIN` |
| POST | `/api/v1/admin/skill-reviews/{reviewId}/restore` | 恢复用户评价(`SKILL_ADMIN` / `SUPER_ADMIN` |
### 用户治理(需 USER_ADMIN / SUPER_ADMIN

View file

@ -1,25 +1,31 @@
# OpenClaw Integration Guide
This document explains how to configure OpenClaw CLI to connect to a SkillHub private registry for publishing, searching, and downloading skills.
This document explains how to configure the ClawHub CLI to connect to a private SkillHub registry for search, inspection, and installation. Use the first-party SkillHub CLI for publishing.
> Not only applicable to Openclaw, but also compatible with other CLI Coding Agents (Claude Code, OpenCode, Qcoder, etc.) or Agent assistants (Nanobot, CoPaw, etc.) by specifying the installation directory.
## Overview
SkillHub provides a ClawHub-compatible API layer, allowing OpenClaw CLI to seamlessly integrate with private registries. With simple configuration, you can:
SkillHub provides a ClawHub-compatible API for common read and install flows. With simple configuration, you can:
- 🔍 Search for private skills within your organization
- 📥 Download and install skill packages
- 📤 Publish new skills to the private registry
- ⭐ Star and rate skills
- ⭐ Star skills
Current compatibility boundaries:
- ClawHub CLI `0.23.3` uses `/api/v1/whoami`, which matches the SkillHub compatibility API.
- ClawHub publishing depends on upload-ticket endpoints that SkillHub does not implement, so `clawhub publish` and `clawhub sync` are not supported.
- ClawHub CLI `0.23.3` does not reliably prioritize the private Registry saved during `login`; site discovery or the default can select another target. Set `CLAWHUB_REGISTRY` in each shell session or pass `--registry` explicitly.
- Canonical slugs use `--` between namespace and skill. SkillHub rejects new namespace or skill slugs containing consecutive `--`, keeping new coordinates unambiguous. Rename invalid legacy or externally imported coordinates before using the ClawHub CLI.
## Quick Start
### 1. Configure Registry URL
Set the SkillHub registry address in your OpenClaw configuration:
Set the SkillHub registry address for the current shell session:
```bash
# Via environment variable (temporary)
# Do not depend on the registry resolution order of the login configuration
export CLAWHUB_REGISTRY=https://skillhub.your-company.com
```
@ -29,7 +35,7 @@ For **global namespace (@global) PUBLIC skills**, no login is required to downlo
- Team namespace skills (regardless of visibility)
- NAMESPACE_ONLY or PRIVATE skills
- Write operations like publishing, starring, etc.
- Authenticated operations such as starring
```bash
# Log in with an API token
@ -107,24 +113,21 @@ npx clawhub uninstall --help
npx clawhub list --help
```
### 5. Publish Skills
### 5. Publish with the SkillHub CLI
The ClawHub CLI `0.23.3` publishing protocol is not compatible with SkillHub. Use the first-party SkillHub CLI:
```bash
# Publish to the global namespace (requires appropriate permissions)
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
# Publish to a team namespace such as my-space
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
npx clawhub sync --all # Upload all skills in current folder
# Help
npx clawhub publish --help
npx clawhub sync --help
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill --namespace global
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space
```
Notes:
- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill`
- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix
- Publishing requires an API Token with the `skill:publish` scope and permission in the target namespace.
- `clawhub login` and the SkillHub CLI do not share credentials; set `SKILLHUB_TOKEN` separately for the first-party CLI.
- The first-party CLI uses a separate namespace option, but it still follows the server's slug validation rules.
## API Endpoints
@ -138,9 +141,9 @@ SkillHub compatibility layer provides the following endpoints:
| `/api/v1/download/{slug}` | GET | Download skill (redirect) | Optional* |
| `/api/v1/download` | GET | Download skill (query params) | Optional* |
| `/api/v1/skills/{slug}` | GET | Get skill details | Optional |
| `/api/v1/skills/{slug}/star` | POST | Star a skill | Required |
| `/api/v1/skills/{slug}/unstar` | DELETE | Unstar a skill | Required |
| `/api/v1/publish` | POST | Publish a skill | Required |
| `/api/v1/stars/{slug}` | POST | Star a skill | Required |
| `/api/v1/stars/{slug}` | DELETE | Unstar a skill | Required |
| `/api/v1/publish` | POST | Legacy compatibility endpoint; not used by ClawHub CLI `0.23.3` | Required |
Notes:
- The compatibility layer may still expose the term "latest" externally, but it must strictly mean "latest published version"
@ -186,6 +189,8 @@ SkillHub internally uses `@{namespace}/{skill}` format, but the compatibility la
OpenClaw CLI uses canonical slug format, and SkillHub handles the conversion automatically.
The canonical format has no escaping rule, so SkillHub rejects new namespace or skill slugs containing consecutive `--`. If legacy or externally imported data bypassed that validation, rename the coordinate first; the first-party CLI does not bypass server-side slug validation.
## Configuration Examples
### ClawHub CLI Environment Variables
@ -248,11 +253,15 @@ curl https://skillhub.your-company.com/api/v1/whoami \
npx clawhub search ""
```
### Q: Permission denied when publishing?
### Q: Why does publishing with the ClawHub CLI fail?
- Publishing to global namespace (`@global`) requires `SUPER_ADMIN` permission
- Publishing to team namespace requires OWNER or ADMIN role in that namespace
- Contact your administrator for appropriate permissions
ClawHub CLI `0.23.3` uses an upload-ticket protocol outside SkillHub's compatibility scope. This failure does not mean that the API Token was revoked; use the first-party SkillHub CLI instead.
If the first-party CLI reports insufficient permission:
- The publisher must be a member of the target namespace; `SUPER_ADMIN` is exempt
- A regular member may submit a publication; visibility and review rules decide whether it is published immediately or enters review
- Contact a namespace administrator to join the target namespace
### Q: Which OpenClaw versions are supported?

View file

@ -1,25 +1,31 @@
# OpenClaw 集成指南
本文档说明如何配置 OpenClaw CLI 连接到 SkillHub 私有注册中心,实现技能的发布、搜索和下载
本文档说明如何配置 ClawHub CLI 连接到 SkillHub 私有注册中心,实现技能的搜索、查看和安装。发布技能请使用第一方 SkillHub CLI
> 不仅适用于 Openclaw通过指定安装目录可适用于其他的 CLI Coding Agent (Claude Code、OpenCode、Qcoder等) 或者 Agent 助手Nanobot、CoPaw等
## 概述
SkillHub 提供了与 ClawHub 兼容的 API 层,使得 OpenClaw CLI 可以无缝对接私有注册中心。通过简单的配置,您可以:
SkillHub 提供 ClawHub 兼容 API覆盖常用的只读发现和安装流程。通过简单配置,您可以:
- 🔍 搜索组织内的私有技能
- 📥 下载和安装技能包
- 📤 发布新技能到私有注册中心
- ⭐ 收藏和评分技能
- ⭐ 收藏技能
当前兼容边界:
- 已验证的 ClawHub CLI `0.23.3` 使用 `/api/v1/whoami`,与 SkillHub 兼容层一致。
- ClawHub CLI 的发布协议依赖 SkillHub 未实现的上传票据接口,因此 `clawhub publish``clawhub sync` 不属于支持范围。
- ClawHub CLI `0.23.3` 不会可靠地优先使用登录时保存的私有 Registry站点发现或默认地址可能覆盖预期目标。每个终端会话都应设置 `CLAWHUB_REGISTRY`,或在命令中显式传入 `--registry`
- canonical slug 使用 `--` 分隔 namespace 与 skill。SkillHub 会拒绝新建包含连续 `--` 的 namespace 或 skill slug以保证新坐标可无歧义解析历史或外部导入的异常坐标应先重命名再使用 ClawHub CLI。
## 快速开始
### 1. 配置 Registry 地址
在 OpenClaw 配置文件中设置 SkillHub 注册中心地址:
为当前终端会话设置 SkillHub 注册中心地址:
```bash
# 通过环境变量配置(临时)
# 不依赖 login 配置的 Registry 解析顺序
export CLAWHUB_REGISTRY=https://skillhub.your-company.com
```
@ -29,7 +35,7 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com
- 团队命名空间的技能(无论可见性)
- NAMESPACE_ONLY 或 PRIVATE 技能
- 发布、收藏等写操作
- 收藏等需要登录的操作
```bash
# 使用 API Token 登录
@ -107,24 +113,21 @@ npx clawhub uninstall --help
npx clawhub list --help
```
### 5. 发布技能
### 5. 使用 SkillHub CLI 发布技能
ClawHub CLI `0.23.3` 的发布协议与 SkillHub 不兼容。请使用第一方 SkillHub CLI
```bash
# 发布到 global 空间(需要相应权限)
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
# 发布到如 my-space 这样的团队空间
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
npx clawhub sync --all # 上传当前文件夹中所有的 skill
# 使用帮助
npx clawhub publish --help
npx clawhub sync --help
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill --namespace global
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space
```
说明:
- `my-space--my-skill` 是兼容层 canonical slugSkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
- 发布需要具有 `skill:publish` scope 的 API Token以及目标 namespace 对应权限。
- `clawhub login` 与 SkillHub CLI 不共享凭据;请为第一方 CLI 单独设置 `SKILLHUB_TOKEN`
- 第一方 CLI 使用独立 namespace 参数,但仍遵循服务端 slug 校验规则。
## API 端点说明
@ -138,9 +141,9 @@ SkillHub 兼容层提供以下端点:
| `/api/v1/download/{slug}` | GET | 下载技能(重定向) | 可选* |
| `/api/v1/download` | GET | 下载技能(查询参数) | 可选* |
| `/api/v1/skills/{slug}` | GET | 获取技能详情 | 可选 |
| `/api/v1/skills/{slug}/star` | POST | 收藏技能 | 必需 |
| `/api/v1/skills/{slug}/unstar` | DELETE | 取消收藏 | 必需 |
| `/api/v1/publish` | POST | 发布技能 | 必需 |
| `/api/v1/stars/{slug}` | POST | 收藏技能 | 必需 |
| `/api/v1/stars/{slug}` | DELETE | 取消收藏 | 必需 |
| `/api/v1/publish` | POST | 旧版兼容发布端点ClawHub CLI `0.23.3` 不使用 | 必需 |
说明:
- 兼容层对外继续使用 “latest” 语义,但这里严格指向“最新已发布版本”
@ -186,6 +189,8 @@ SkillHub 内部使用 `@{namespace}/{skill}` 格式,但兼容层会自动转
OpenClaw CLI 使用 canonical slug 格式SkillHub 会自动处理转换。
canonical 格式没有转义规则,因此 SkillHub 会拒绝新建包含连续 `--` 的 namespace 或 skill slug。若历史或外部导入数据绕过了该校验应先重命名坐标第一方 SkillHub CLI 虽使用独立 `--namespace` 参数,也不能绕过服务端 slug 校验。
## 配置示例
### ClawHub CLI 环境变量配置
@ -248,11 +253,15 @@ curl https://skillhub.your-company.com/api/v1/whoami \
npx clawhub search ""
```
### Q: 发布技能时提示权限不足
### Q: 使用 ClawHub CLI 发布为什么失败
- 发布到全局命名空间(`@global`)需要 `SUPER_ADMIN` 权限
- 发布到团队命名空间需要是该命名空间的 OWNER 或 ADMIN
- 联系管理员分配相应权限
ClawHub CLI `0.23.3` 使用的上传票据协议不在 SkillHub 兼容范围内。该错误不代表 API Token 已撤销;请改用第一方 SkillHub CLI。
如果第一方 CLI 提示权限不足:
- 发布者必须是目标命名空间成员;`SUPER_ADMIN` 例外
- 普通成员可提交发布,是否直接发布或进入审核由可见性和审核规则决定
- 联系命名空间管理员加入目标空间
### Q: 支持哪些 OpenClaw 版本?

View file

@ -43,11 +43,13 @@ skillhub:
scan-path: /scan-upload
mode: local
connect-timeout-ms: 5000
read-timeout-ms: 300000
read-timeout-ms: 900000
retry-max-attempts: 3
stream:
key: skillhub:scan:requests
group: skillhub-scanners
reclaim-min-idle: PT16M
max-unavailable-age: PT1H
```
Important environment variables:
@ -55,16 +57,24 @@ Important environment variables:
- `SKILLHUB_SECURITY_SCANNER_ENABLED`
- `SKILLHUB_SECURITY_SCANNER_URL`
- `SKILLHUB_SECURITY_SCANNER_MODE`
- `SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT`
- `SKILLHUB_SCAN_STREAM_KEY`
- `SKILLHUB_SCAN_STREAM_GROUP`
- `SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE`
- `SKILLHUB_SECURITY_STREAM_MAX_UNAVAILABLE_AGE`
Scanner-side optional environment variables:
- `SKILL_SCANNER_LLM_API_KEY`
- `SKILL_SCANNER_LLM_BASE_URL`
- `SKILL_SCANNER_LLM_MODEL`
- `SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS` (default `1`)
- `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS` (default `930`)
If the LLM variables are absent, the scanner should still run with non-LLM analyzers.
The default timeout ordering is server read timeout (900 seconds), scanner hard timeout
(930 seconds), then pending-message reclaim (960 seconds). A hard timeout exits the scanner process
with status `124`; Compose or Kubernetes restarts it and the Redis pending task is retried.
## Kubernetes Notes
@ -126,7 +136,11 @@ Response fields include:
## Failure Semantics
- scan task retries are handled by `AbstractStreamConsumer`
- final failure marks the version as `SCAN_FAILED`
- scanner connection failures, HTTP 429, and HTTP 5xx remain pending for automatic recovery
- unavailable tasks older than `max-unavailable-age` are marked `SCAN_FAILED`, acknowledged, and removed from the Redis Stream
- the timeout is evaluated during pending reclaim; terminal handling can occur roughly one `reclaim-min-idle` plus one `reclaim-interval` after the configured age
- terminal failures retain a failure reason in the security audit response for operators and authorized users
- other final failures mark the version as `SCAN_FAILED` after retry exhaustion
- even after scan failure, a review task is still created so the package does not get stuck forever
This keeps the existing human review path intact while making scanner failures visible.

View file

@ -65,8 +65,10 @@ npx clawhub search email
# Install a skill package
npx clawhub install my-skill
# Publish a skill package
npx clawhub publish ./my-skill
# Publish a skill package (the ClawHub CLI publishing protocol is not compatible)
export SKILLHUB_REGISTRY=http://your-skillhub-host:8080
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill
```
## Q: How do I configure HTTPS?

View file

@ -157,7 +157,7 @@ skillhub install pdf-parser --agent codex --agent claude-code
# Install to custom directory
skillhub install pdf-parser --dir ~/.claude/skills
# Force overwrite existing installation
# Reinstall a SkillHub-managed installation from the same source
skillhub install pdf-parser --force
```
@ -214,15 +214,48 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
```json
{
"schemaVersion": 1,
"registry": "https://skill.xfyun.cn",
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"versionId": 123,
"fingerprint": "sha256:...",
"files": { "SKILL.md": "sha256..." },
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
The CLI creates this file after extracting the registry package. It is not included in the downloaded
ZIP, and `.skillhub/` is excluded when an installed directory is published again.
## Upgrade Installed Skills
```bash
# Preview without changing files
skillhub upgrade @global/skillhub-registry --check
# Upgrade one or a bounded list of installed Skills
skillhub upgrade @global/skillhub-registry
skillhub upgrade @team/code-review @team/java-guide
# Deterministic machine-readable plan
skillhub upgrade @team/code-review --check --json
```
`upgrade` never installs a missing Skill and has no implicit upgrade-all mode. Local changes require
`--force`; it only replaces a managed installation whose full `registry + namespace + slug` source
matches.
All targets in one inventory entry are upgraded together. A filter that selects only part of that
entry is rejected because the current inventory format stores one shared version for all targets.
The command also keeps the local files when the registry resolves to an older version.
If a multi-Skill run fails after an earlier upgrade commits, execution stops and reports each item
as `upgraded`, `failed`, or `not-attempted`; a committed upgrade is never rolled back implicitly.
New installations store absolute target paths. Reinstall an older entry that still contains relative
target paths before upgrading it; the CLI cannot safely infer the original working directory.
## Local Management
### List Installed Skills
@ -504,11 +537,26 @@ Options:
- `--version <v>` — Version (default: latest)
- `--agent <profile>` — Agent profile (repeatable)
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
- `--force`Overwrite existing installation
- `--force`Replace an existing SkillHub-managed installation from the same source
- `--registry <url>` — Registry URL
- `--token <token>` — API token
- `--json` — JSON output
### upgrade
```bash
skillhub upgrade <coordinate...> [options]
```
Options:
- `--namespace <slug>` — Filter a bare slug by namespace
- `--agent <profile>` — Filter installed targets by Agent (repeatable)
- `--dir <path>` — Filter installed targets by directory
- `--registry <url>` — Filter by installation source registry
- `--check` — Print the exact plan without modifying files
- `--force` — Replace local changes only for the same full source identity
- `--json` — JSON output
### list
```bash
@ -604,7 +652,7 @@ skillhub search test --registry https://skillhub.example.com
### Installation Directory Conflict
```bash
# Use --force to overwrite
# Reinstall only when the existing directory has matching SkillHub source metadata
skillhub install pdf-parser --force
# Or remove first then install
@ -612,6 +660,9 @@ skillhub remove pdf-parser
skillhub install pdf-parser
```
`--force` never overwrites an unmanaged directory or a Skill installed from another registry,
namespace, or slug. Move or explicitly remove that directory first.
### Corrupted Inventory
```bash

View file

@ -57,16 +57,19 @@ Ensure skill package conforms to SkillHub specification:
2. **Publish via CLI (Recommended)**
```bash
# Configure registry
export CLAWHUB_REGISTRY=http://localhost:8080
# Configure the SkillHub registry
export SKILLHUB_REGISTRY=http://localhost:8080
export SKILLHUB_TOKEN=YOUR_API_TOKEN
# Publish to default namespace
npx clawhub publish ./my-skill
npx @astron-team/skillhub@latest publish ./my-skill
# Publish to specific namespace
npx clawhub publish ./my-skill --namespace my-team
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team
```
> The ClawHub CLI publish and sync protocols are not compatible with SkillHub. Use the SkillHub CLI above for publishing.
3. **Publish via Web UI**
Visit `http://localhost:3000/dashboard/publish`, select namespace, upload zip file, choose visibility, and click "Publish".

View file

@ -60,6 +60,13 @@ View skill packages with the most stars and highest ratings to discover best pra
3. The rating takes effect immediately and impacts the skill package's average rating
4. You can update your rating at any time
**Writing a Review**:
1. Select "Write a review" on a published skill's detail page
2. Choose 1-5 stars and enter up to 2,000 characters
3. You can edit or clear the text; clearing it keeps the star rating and remains available if the skill is later unpublished
4. A review hidden by an administrator stays hidden after author edits and becomes public only after an administrator restores it
**Viewing Notifications**:
1. Click the notification icon in the top navigation bar
@ -67,6 +74,11 @@ View skill packages with the most stars and highest ratings to discover best pra
3. Click a notification to navigate to the relevant page
4. Mark as read or mark all as read
The notification list and unread count refresh through ordinary HTTP requests every 10 seconds and
immediately when the window regains focus. The legacy `GET /api/v1/notifications/sse` endpoint has
been removed. Custom clients should poll `GET /api/v1/notifications` and
`GET /api/v1/notifications/unread-count` instead.
**Viewing My Stars**:
1. Navigate to `/dashboard/stars`
@ -125,6 +137,24 @@ GET /api/v1/me/stars?page=0&size=20
GET /api/v1/skills/{skillId}/rating
```
**Review APIs**:
```bash
# Public review list
GET /api/v1/skills/{skillId}/reviews?page=0&size=20
# Read, create, or update the current user's review
GET /api/v1/skills/{skillId}/reviews/me
PUT /api/v1/skills/{skillId}/reviews/me
# Clear review text while retaining the star rating
DELETE /api/v1/skills/{skillId}/reviews/me
# SKILL_ADMIN or SUPER_ADMIN moderation
POST /api/v1/admin/skill-reviews/{reviewId}/hide
POST /api/v1/admin/skill-reviews/{reviewId}/restore
```
**Response Example**:
```json
{
@ -137,6 +167,8 @@ GET /api/v1/skills/{skillId}/rating
> **Rating Rules**: Each user can rate each skill package only once. Ratings can be updated but not deleted.
> **Review Rules**: Only published skills can be reviewed, and the public list contains visible reviews only. Refresh and retry after a concurrent-update conflict.
- **Star Count**: A skill package's star count is displayed in search results and on the detail page
- **Average Rating**: A skill package's average rating affects search ranking
- **Notification Settings**: Users can disable certain notification types in their settings

View file

@ -128,22 +128,25 @@ In the browser, you can add the `X-Mock-User-Id` header via a browser extension
## Install the CLI Tool
SkillHub is compatible with the OpenClaw CLI. You can use the `npx clawhub` command to manage skill packages:
Use the first-party SkillHub CLI for skill package management:
```bash
# Configure the SkillHub registry URL
export CLAWHUB_REGISTRY=http://localhost:8080
# Install the CLI and configure the SkillHub registry URL
npm install -g @astron-team/skillhub
export SKILLHUB_REGISTRY=http://localhost:8080
# Search for skill packages
npx clawhub search email
skillhub search email
# Install a skill package
npx clawhub install my-skill
skillhub install my-skill
# Publish a skill package
npx clawhub publish ./my-skill
skillhub publish ./my-skill --namespace my-team
```
Existing ClawHub workflows can still use the compatibility layer for search and install. Prefer the SkillHub CLI for new workflows and publishing.
## Publish Your First Skill Package
### Publish via CLI (Recommended)
@ -164,13 +167,10 @@ my-skill/
```bash
# Configure the registry
export CLAWHUB_REGISTRY=http://localhost:8080
# Publish to the default namespace
npx clawhub publish ./my-skill
export SKILLHUB_REGISTRY=http://localhost:8080
# Publish to a specific namespace
npx clawhub publish ./my-skill --namespace my-team
skillhub publish ./my-skill --namespace my-team
```
3. **Wait for security scanning**
@ -201,13 +201,13 @@ Administrators will receive a notification and the skill package will be officia
```bash
# Search for skill packages
npx clawhub search pdf
skillhub search pdf
# Install a skill package
npx clawhub install pdf-parser
skillhub install pdf-parser
# Install a skill package from a specific namespace
npx clawhub install my-team--pdf-parser
skillhub install pdf-parser --namespace my-team
```
### Using the Web UI

View file

@ -65,8 +65,10 @@ npx clawhub search email
# 安装技能包
npx clawhub install my-skill
# 发布技能包
npx clawhub publish ./my-skill
# 发布技能包ClawHub CLI 的发布协议不兼容 SkillHub
export SKILLHUB_REGISTRY=http://your-skillhub-host:8080
export SKILLHUB_TOKEN=YOUR_API_TOKEN
npx @astron-team/skillhub@latest publish ./my-skill
```
## Q: 如何配置 HTTPS

View file

@ -57,16 +57,19 @@ SkillHub 提供了类似 npm 的发布体验,但增加了企业级的权限控
2. **使用 CLI 发布(推荐)**
```bash
# 配置注册中心
export CLAWHUB_REGISTRY=http://localhost:8080
# 配置 SkillHub 注册中心
export SKILLHUB_REGISTRY=http://localhost:8080
export SKILLHUB_TOKEN=YOUR_API_TOKEN
# 发布到默认命名空间
npx clawhub publish ./my-skill
npx @astron-team/skillhub@latest publish ./my-skill
# 发布到指定命名空间
npx clawhub publish ./my-skill --namespace my-team
npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team
```
> ClawHub CLI 的发布与同步协议与 SkillHub 不兼容。发布请使用上面的 SkillHub CLI。
3. **使用 Web UI 发布**
访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。

View file

@ -60,6 +60,13 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享
3. 评分会立即生效,影响技能包的平均评分
4. 可以随时修改评分
**撰写评价**
1. 在已发布技能的详情页点击「写评价」
2. 选择 1-5 星并填写最多 2000 字的评价
3. 可以修改或清空评价;清空评价不会删除星级评分,即使技能之后取消发布也仍可清空自己的文字
4. 被管理员隐藏的评价在重新编辑后仍保持隐藏,只有技能管理员或超级管理员可以恢复公开
**查看通知**
1. 点击顶部导航栏的通知图标
@ -67,6 +74,10 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享
3. 点击通知跳转到相关页面
4. 标记为已读或全部已读
通知列表和未读数每 10 秒通过普通 HTTP 请求刷新,并在窗口重新获得焦点时立即刷新。旧版
`GET /api/v1/notifications/sse` 接口已移除;自建客户端应改为轮询
`GET /api/v1/notifications``GET /api/v1/notifications/unread-count`
**查看我的收藏**
1. 访问 `/dashboard/stars`
@ -125,6 +136,24 @@ GET /api/v1/me/stars?page=0&size=20
GET /api/v1/skills/{skillId}/rating
```
**评价接口**
```bash
# 公开评价列表
GET /api/v1/skills/{skillId}/reviews?page=0&size=20
# 查看、创建或修改自己的评价
GET /api/v1/skills/{skillId}/reviews/me
PUT /api/v1/skills/{skillId}/reviews/me
# 清空评价文字并保留星级评分
DELETE /api/v1/skills/{skillId}/reviews/me
# 技能管理员或超级管理员隐藏、恢复评价
POST /api/v1/admin/skill-reviews/{reviewId}/hide
POST /api/v1/admin/skill-reviews/{reviewId}/restore
```
**响应示例**
```json
{
@ -137,6 +166,8 @@ GET /api/v1/skills/{skillId}/rating
> **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。
> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。
- **星标数量**:技能包的星标数会显示在搜索结果和详情页
- **平均评分**:技能包的平均评分会影响搜索排序
- **通知设置**:用户可以在设置中关闭某些类型的通知

View file

@ -128,22 +128,25 @@ curl -H "X-Mock-User-Id: local-admin" http://localhost:8080/api/v1/auth/me
## 安装 CLI 工具
SkillHub 兼容 OpenClaw CLI可以使用 `npx clawhub` 命令管理技能包:
推荐使用第一方 SkillHub CLI 管理技能包:
```bash
# 配置 SkillHub 注册中心地址
export CLAWHUB_REGISTRY=http://localhost:8080
# 安装并配置 SkillHub 注册中心地址
npm install -g @astron-team/skillhub
export SKILLHUB_REGISTRY=http://localhost:8080
# 搜索技能包
npx clawhub search email
skillhub search email
# 安装技能包
npx clawhub install my-skill
skillhub install my-skill
# 发布技能包
npx clawhub publish ./my-skill
skillhub publish ./my-skill --namespace my-team
```
已有 ClawHub 工作流仍可通过兼容层进行搜索和安装;新流程及发布操作优先使用 SkillHub CLI。
## 发布第一个技能包
### 使用 CLI 工具发布(推荐)
@ -164,13 +167,10 @@ my-skill/
```bash
# 配置注册中心
export CLAWHUB_REGISTRY=http://localhost:8080
# 发布到默认命名空间
npx clawhub publish ./my-skill
export SKILLHUB_REGISTRY=http://localhost:8080
# 发布到指定命名空间
npx clawhub publish ./my-skill --namespace my-team
skillhub publish ./my-skill --namespace my-team
```
3. **等待安全扫描**
@ -201,13 +201,13 @@ npx clawhub publish ./my-skill --namespace my-team
```bash
# 搜索技能包
npx clawhub search pdf
skillhub search pdf
# 安装技能包
npx clawhub install pdf-parser
skillhub install pdf-parser
# 安装指定命名空间的技能包
npx clawhub install my-team--pdf-parser
skillhub install pdf-parser --namespace my-team
```
### 使用 Web UI

View file

@ -2,11 +2,11 @@
## Goal
Build an independent in-app notification subsystem for SkillHub that delivers real-time notifications for skill lifecycle events (publish, review, promotion, report), with SSE push, user preference control, and extensibility for future third-party channels.
Build an independent in-app notification subsystem for SkillHub that delivers near-real-time notifications for skill lifecycle events (publish, review, promotion, report), with HTTP polling, user preference control, and extensibility for future third-party channels.
## Scope
- **In scope**: In-app notifications, SSE real-time push, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup
- **In scope**: In-app notifications, 10-second HTTP polling, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup
- **Out of scope**: External channels (email, Feishu, DingTalk), migration of existing governance notifications, external webhook delivery
## Architecture
@ -22,13 +22,11 @@ Domain Events (existing + new)
└── NotificationModule (NEW)
├── NotificationEventListener
├── RecipientResolver
├── NotificationPreferenceService (filter)
├── NotificationDispatcher (channel routing)
├── NotificationService (persist)
└── SseEmitterManager (push)
├── NotificationPreferenceService (preference CRUD)
└── NotificationService (preference filter + persist + HTTP reads)
```
The notification module consumes domain events via `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("skillhubEventExecutor")`, following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is lightweight (DB insert + SSE push).
The notification module consumes domain events via `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("skillhubEventExecutor")`, following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is a lightweight database insert. Clients discover persisted changes through HTTP polling.
## Data Model
@ -163,30 +161,24 @@ skillhub-notification/ -- new module (depends on: skillhub-do
│ ├── NotificationPreference.java
│ ├── NotificationRepository.java
│ └── NotificationPreferenceRepository.java
├── service/
│ ├── NotificationService.java -- CRUD: create, list, mark read, batch read, unread count
│ ├── NotificationPreferenceService.java -- preference CRUD + default fallback
│ └── NotificationDispatcher.java -- route by channel (currently IN_APP only)
└── sse/
└── SseEmitterManager.java -- manage SSE connections: register, push, heartbeat, cleanup
└── service/
├── NotificationService.java -- apply preference, create, list, mark read, batch read, unread count
└── NotificationPreferenceService.java -- preference CRUD + default fallback
skillhub-app/ -- existing module
└── listener/
├── NotificationEventListener.java -- consume domain events, call RecipientResolver + Dispatcher
├── NotificationEventListener.java -- consume domain events, call RecipientResolver + NotificationService
└── RecipientResolver.java -- resolve recipient list per event type (needs auth + domain repos)
```
## SSE Real-Time Push
## HTTP Polling
- Endpoint: `GET /api/notifications/sse`
- `SseEmitterManager` uses `ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>` (thread-safe for concurrent tab open/close)
- Per-user connection limit: max 5 emitters (reject new connections beyond limit)
- Global connection limit: max 1000 emitters (configurable, reject with 503 when exceeded)
- SseEmitter timeout: 60s, browser `EventSource` auto-reconnects
- Heartbeat: `:ping` every 30s to prevent proxy/LB disconnection
- On emitter complete/timeout/error: auto-remove from map
- Push failure: silent ignore (notification already persisted, visible on refresh)
- On `EventSource` reconnect: frontend fetches unread count to sync badge
- The global bell polls `GET /api/notifications/unread-count` every 10 seconds while a user is authenticated.
- An active dropdown or notification page polls its paginated `GET /api/notifications` query every 10 seconds.
- Polling pauses while the browser tab is in the background.
- Window focus and network reconnect trigger a fresh request.
- Poll responses are authoritative; the unread badge uses the server count instead of incrementing a client-side event counter.
- Closing the dropdown unmounts its list query, so the full notification list is not polled when it is not visible.
## API Design
@ -195,8 +187,6 @@ GET /api/notifications -- List (paginated + category filter)
GET /api/notifications/unread-count -- Unread count (for bell badge)
PUT /api/notifications/{id}/read -- Mark single as read
PUT /api/notifications/read-all -- Mark all as read
GET /api/notifications/sse -- SSE connection
GET /api/notification-preferences -- Get current user preferences
PUT /api/notification-preferences -- Batch update preferences
```
@ -208,10 +198,12 @@ Response format follows existing SkillHub API conventions (code + data wrapper).
### Bell Component (global nav bar)
- Bell icon in nav bar, left of user avatar
- Red badge with unread count (> 99 shows "99+")
- Polls the unread count over HTTP every 10 seconds while the tab is visible
- Click to expand dropdown
### Dropdown List
- Shows latest 5 notifications
- Polls the visible list over HTTP every 10 seconds
- Each item: title + relative time ("3 minutes ago")
- Click item → navigate to entity page + mark as read
- Footer: "View all notifications" link
@ -219,6 +211,7 @@ Response format follows existing SkillHub API conventions (code + data wrapper).
### Notification Page (`/dashboard/notifications`)
- Full notification list with pagination
- Polls the visible page over HTTP every 10 seconds
- Tab filter by category: All / Publish / Review / Promotion / Report
- Batch mark all as read
- Click to navigate
@ -242,8 +235,6 @@ Response format follows existing SkillHub API conventions (code + data wrapper).
```yaml
skillhub:
notification:
sse-timeout: 60s
sse-heartbeat: 30s
cleanup:
read-retention-days: 30
unread-retention-days: 90
@ -260,6 +251,5 @@ skillhub:
## Extensibility
- New event types: add domain event record + mapping in `NotificationEventListener`
- New channels: add enum value to `NotificationChannel` + implement channel-specific dispatcher
- Third-party integrations: add new `@TransactionalEventListener` beans that consume the same domain events
- Preference table already supports category × channel granularity, no schema change needed

View file

@ -5,6 +5,7 @@ ARG SKILL_SCANNER_VERSION=1.0.2
WORKDIR /app
COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py
COPY skillhub_scanner_app.py /app/skillhub_scanner_app.py
RUN pip install --no-cache-dir \
"cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \
@ -23,4 +24,4 @@ EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO- http://127.0.0.1:8000/health || exit 1
CMD ["skill-scanner-api", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "skillhub_scanner_app:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -21,6 +21,7 @@ skillhub:
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
read-timeout-ms: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:900000}
# 分析器配置
analyzers:
@ -39,6 +40,10 @@ skillhub:
preset: ${SKILLHUB_SCANNER_POLICY_PRESET:balanced}
custom-policy-path: ${SKILLHUB_SCANNER_CUSTOM_POLICY_PATH:}
fail-on-severity: ${SKILLHUB_SCANNER_FAIL_ON_SEVERITY:high}
stream:
# Keep this greater than scanner.read-timeout-ms.
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT16M}
```
## 配置项详解
@ -110,6 +115,15 @@ mode: upload
mode: upload
```
#### 扫描超时与并发
- `SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT`:服务端等待单次扫描的毫秒数,默认 15 分钟。
- `SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE`:未确认任务允许被恢复的等待时间,应大于扫描超时,默认 16 分钟。
- `SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS`Scanner 容器内的最大并发扫描数,默认 `1`;超出的请求返回 HTTP 503由待处理消息稍后重试。
- `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS`Scanner 单次工作的硬上限,默认 930 秒。超时后进程以状态码 `124` 退出,由 Compose/Kubernetes 重启;默认关系为服务端等待 900 秒 < Scanner 硬上限 930 < Redis 恢复等待 960 进程退出会使同容器内其他扫描稍后重试因此建议保持默认并发数 `1`
Scanner 超时或暂时不可用(包括 429、5xx版本不会进入 `SCAN_FAILED`。任务保留为待处理,版本保持 `SCANNING`,待 Scanner 恢复后自动继续。确定性的 4xx 包校验错误仍采用有限次数重试,最终可以进入 `SCAN_FAILED`。某个包若稳定触发 Scanner 内部 500会保持 `SCANNING` 等待 Scanner 修复,而不会被误判为包本身不合格。
---
### 2. 分析器配置

View file

@ -0,0 +1,87 @@
"""Runtime safeguards around the upstream Cisco Skill Scanner ASGI application."""
import asyncio
import logging
import os
import shutil
import tempfile
from pathlib import Path
from typing import NoReturn
from fastapi import Request
from fastapi.responses import JSONResponse
from skill_scanner.api.api import app
_MAX_CONCURRENT_SCANS = max(1, int(os.getenv("SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS", "1")))
_HARD_TIMEOUT_SECONDS = max(1, int(os.getenv("SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS", "930")))
_active_scans = 0
_active_scans_guard = asyncio.Lock()
_SCAN_PATHS = {"/scan", "/scan-upload"}
_log = logging.getLogger(__name__)
def _cleanup_stale_scan_directories(temp_root: Path | None = None) -> None:
"""Remove incomplete upstream extraction directories left by a process restart."""
root = temp_root or Path(tempfile.gettempdir())
for candidate in root.glob("skill_scanner_*"):
if not candidate.is_dir():
continue
try:
shutil.rmtree(candidate)
except OSError as error:
_log.warning("Could not remove stale scanner directory %s: %s", candidate, error)
def _restart_after_hard_timeout(request_path: str) -> NoReturn:
"""Terminate the single-scan worker so the container runtime can recover it."""
_log.critical(
"Security scan exceeded the %s second hard timeout: path=%s; restarting scanner",
_HARD_TIMEOUT_SECONDS,
request_path,
)
logging.shutdown()
os._exit(124)
async def _await_scan_until(scan_task: asyncio.Task, deadline: float, request_path: str):
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
_restart_after_hard_timeout(request_path)
try:
return await asyncio.wait_for(asyncio.shield(scan_task), timeout=remaining)
except asyncio.TimeoutError:
_restart_after_hard_timeout(request_path)
app.router.add_event_handler("startup", _cleanup_stale_scan_directories)
@app.middleware("http")
async def limit_concurrent_scans(request: Request, call_next):
"""Reject excess scan work so timed-out client retries cannot multiply memory use."""
global _active_scans
if request.method != "POST" or request.url.path not in _SCAN_PATHS:
return await call_next(request)
async with _active_scans_guard:
if _active_scans >= _MAX_CONCURRENT_SCANS:
return JSONResponse(
status_code=503,
content={"detail": "Scanner is busy; retry later"},
headers={"Retry-After": "30"},
)
_active_scans += 1
scan_task = asyncio.create_task(call_next(request))
deadline = asyncio.get_running_loop().time() + _HARD_TIMEOUT_SECONDS
try:
# Keep the capacity slot until upstream work really ends, even if the HTTP client
# disconnects while the scanner's worker thread is still running.
return await _await_scan_until(scan_task, deadline, request.url.path)
except asyncio.CancelledError:
await _await_scan_until(scan_task, deadline, request.url.path)
raise
finally:
async with _active_scans_guard:
_active_scans -= 1

View file

@ -0,0 +1,131 @@
import asyncio
import importlib.util
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
class _FakeRouter:
def __init__(self):
self.handlers = []
def add_event_handler(self, _event, _handler):
self.handlers.append((_event, _handler))
class _FakeApp:
def __init__(self):
self.router = _FakeRouter()
def middleware(self, _kind):
return lambda function: function
class _FakeResponse:
def __init__(self, status_code, content, headers):
self.status_code = status_code
self.content = content
self.headers = headers
class _Request:
method = "POST"
url = types.SimpleNamespace(path="/scan-upload")
def _load_module():
fastapi = types.ModuleType("fastapi")
fastapi.Request = object
responses = types.ModuleType("fastapi.responses")
responses.JSONResponse = _FakeResponse
api = types.ModuleType("skill_scanner.api.api")
api.app = _FakeApp()
stubs = {
"fastapi": fastapi,
"fastapi.responses": responses,
"skill_scanner": types.ModuleType("skill_scanner"),
"skill_scanner.api": types.ModuleType("skill_scanner.api"),
"skill_scanner.api.api": api,
}
with patch.dict(sys.modules, stubs):
module_path = Path(__file__).parents[1] / "skillhub_scanner_app.py"
spec = importlib.util.spec_from_file_location("skillhub_scanner_app_under_test", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class SkillHubScannerAppTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.module = _load_module()
async def test_excess_scan_is_rejected(self):
self.module._active_scans = 1
response = await self.module.limit_concurrent_scans(_Request(), lambda _request: None)
self.assertEqual(503, response.status_code)
self.assertEqual("30", response.headers["Retry-After"])
async def test_client_disconnect_keeps_slot_until_scan_finishes(self):
release = asyncio.Event()
async def scan(_request):
await release.wait()
return "done"
request_task = asyncio.create_task(self.module.limit_concurrent_scans(_Request(), scan))
await asyncio.sleep(0)
request_task.cancel()
await asyncio.sleep(0)
self.assertEqual(1, self.module._active_scans)
response = await self.module.limit_concurrent_scans(_Request(), scan)
self.assertEqual(503, response.status_code)
release.set()
with self.assertRaises(asyncio.CancelledError):
await request_task
self.assertEqual(0, self.module._active_scans)
async def test_hard_timeout_requests_process_restart(self):
self.module._HARD_TIMEOUT_SECONDS = 0.01
async def stuck_scan(_request):
await asyncio.Event().wait()
with patch.object(
self.module,
"_restart_after_hard_timeout",
side_effect=RuntimeError("restart requested")) as restart:
with self.assertRaisesRegex(RuntimeError, "restart requested"):
await self.module.limit_concurrent_scans(_Request(), stuck_scan)
restart.assert_called_once_with("/scan-upload")
self.assertEqual(0, self.module._active_scans)
async def test_startup_cleanup_removes_only_scanner_directories(self):
with tempfile.TemporaryDirectory() as temp_root:
root = Path(temp_root)
stale = root / "skill_scanner_abcd"
unrelated = root / "skillhub-data"
stale.mkdir()
unrelated.mkdir()
self.module._cleanup_stale_scan_directories(root)
self.assertFalse(stale.exists())
self.assertTrue(unrelated.exists())
async def test_startup_cleanup_is_registered_on_the_upstream_router(self):
self.assertEqual(
[("startup", self.module._cleanup_stale_scan_directories)],
self.module.app.router.handlers,
)
if __name__ == "__main__":
unittest.main()

View file

@ -8,7 +8,7 @@ FAIL=0
USER_COOKIE="$(mktemp)"
ADMIN_COOKIE="$(mktemp)"
WORK_DIR="$(mktemp -d)"
SLUG="psmoke$(date +%s)"
SLUG="psmoke$(date +%s)${RANDOM}"
cleanup() {
rm -f "$USER_COOKIE" "$ADMIN_COOKIE"
@ -89,54 +89,132 @@ if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then
exit 1
fi
GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/namespaces/global")"
assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0"
GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")"
CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/namespaces" \
-d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")"
assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0"
assert_code "Admin can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0"
NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")"
cat > "$WORK_DIR/SKILL.md" <<'EOF'
ADD_MEMBER_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \
-d '{"userId":"local-user","role":"MEMBER"}')"
assert_code "Admin can add the regular user as a namespace member" "$ADD_MEMBER_RESPONSE" "0"
cat > "$WORK_DIR/SKILL.md" <<EOF
---
name: Promotion Smoke Skill
name: Promotion Smoke $SLUG
description: Promotion smoke test
version: 1.0.0
---
Body
EOF
(cd "$WORK_DIR" && zip -q skill.zip SKILL.md)
python3 - "$WORK_DIR" <<'PY'
from pathlib import Path
import sys
import zipfile
work_dir = Path(sys.argv[1])
with zipfile.ZipFile(work_dir / "skill.zip", "w", zipfile.ZIP_DEFLATED) as archive:
archive.write(work_dir / "SKILL.md", "SKILL.md")
PY
PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-F "file=@$WORK_DIR/skill.zip;type=application/zip" \
-F "visibility=PUBLIC" \
"$BASE_URL/api/web/skills/$SLUG/publish")"
assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0"
assert_code "Regular user can publish a team skill for review" "$PUBLISH_RESPONSE" "0"
SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")"
SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")"
REVIEW_READY=false
SKILL_DETAIL_RESPONSE=""
for _ in $(seq 1 60); do
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
if JSON_INPUT="$SKILL_DETAIL_RESPONSE" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"]).get("data") or {}
versions = [data.get("ownerPreviewVersion") or {}, data.get("headlineVersion") or {}]
raise SystemExit(0 if any(version.get("status") == "PENDING_REVIEW" for version in versions) else 1)
PY
then
REVIEW_READY=true
break
fi
sleep 1
done
assert_code "Regular user can load the submitted team skill" "$SKILL_DETAIL_RESPONSE" "0"
if [[ "$REVIEW_READY" != "true" ]]; then
fail "Published team skill did not finish scanning within 60 seconds"
exit 1
fi
pass "Published team skill is ready for review"
PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")"
assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0"
REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")"
assert_code "Admin can list pending skill reviews" "$PENDING_REVIEWS_RESPONSE" "0"
REVIEW_ID="$(JSON_INPUT="$PENDING_REVIEWS_RESPONSE" python3 - "$SKILL_SLUG" <<'PY'
import json
import os
import sys
skill_slug = sys.argv[1]
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["skillSlug"] == skill_slug), None)
print(match["id"] if match else "")
PY
)"
if [[ -z "$REVIEW_ID" ]]; then
fail "Pending skill reviews should contain the published team skill"
exit 1
fi
pass "Pending skill reviews contain the published team skill"
APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \
-d '{"comment":"ok"}')"
assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0"
-d '{"comment":"approved by promotion smoke"}')"
assert_code "Admin can approve the regular user's team skill" "$APPROVE_REVIEW_RESPONSE" "0"
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0"
VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")"
SKILL_DETAIL_RESPONSE=""
PROMOTION_READY=false
for _ in $(seq 1 60); do
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
if JSON_INPUT="$SKILL_DETAIL_RESPONSE" python3 - <<'PY'
import json
import os
response = json.loads(os.environ["JSON_INPUT"])
data = response.get("data") or {}
headline_version = data.get("headlineVersion") or {}
raise SystemExit(0 if headline_version.get("id") and data.get("canSubmitPromotion") else 1)
PY
then
PROMOTION_READY=true
break
fi
sleep 1
done
assert_code "Regular user can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0"
if [[ "$PROMOTION_READY" != "true" ]]; then
fail "Published team skill did not become promotable within 60 seconds"
exit 1
fi
VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.headlineVersion.id")"
CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")"
if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then
pass "Approved team skill is marked promotable"
@ -146,21 +224,22 @@ fi
MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/me/skills")"
assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0"
assert_code "Regular user can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0"
if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
import json
import os
import sys
skill_id = int(sys.argv[1])
items = json.loads(os.environ["JSON_INPUT"])["data"]
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next(item for item in items if item["id"] == skill_id)
raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1)
headline_version = match.get("headlineVersion") or {}
raise SystemExit(0 if match["canSubmitPromotion"] and headline_version.get("id") else 1)
PY
then
pass "My skills response exposes promotion submission fields"
else
fail "My skills response should expose latestVersionId and canSubmitPromotion"
fail "My skills response should expose headlineVersion and canSubmitPromotion"
fi
SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
@ -168,7 +247,7 @@ SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions" \
-d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")"
assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0"
assert_code "Regular user can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0"
PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/promotions?status=PENDING")"
@ -188,6 +267,105 @@ else
fail "Pending promotions list should include submitted team skill"
fi
PROMOTION_ID="$(JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
import json
import os
import sys
skill_id = int(sys.argv[1])
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["sourceSkillId"] == skill_id), None)
print(match["id"] if match else "")
PY
)"
if [[ -z "$PROMOTION_ID" ]]; then
fail "Pending promotions should contain the submitted team skill"
exit 1
fi
UNAUTHORIZED_APPROVAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions/$PROMOTION_ID/approve" \
-d '{"comment":"unauthorized"}')"
assert_code "Regular user cannot approve a promotion" "$UNAUTHORIZED_APPROVAL_RESPONSE" "403"
PROMOTION_AFTER_DENIAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/promotions?status=PENDING")"
assert_code "Admin can reload pending promotions after denied approval" "$PROMOTION_AFTER_DENIAL_RESPONSE" "0"
if JSON_INPUT="$PROMOTION_AFTER_DENIAL_RESPONSE" python3 - "$PROMOTION_ID" <<'PY'
import json
import os
import sys
promotion_id = int(sys.argv[1])
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["id"] == promotion_id), None)
raise SystemExit(0 if match and match["status"] == "PENDING" and match.get("targetSkillId") is None else 1)
PY
then
pass "Denied approval keeps the promotion pending without a target"
else
fail "Denied approval should keep the promotion pending without a target"
fi
APPROVE_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions/$PROMOTION_ID/approve" \
-d '{"comment":"approved by promotion smoke"}')"
assert_code "Admin can approve promotion to global namespace" "$APPROVE_PROMOTION_RESPONSE" "0"
TARGET_SKILL_ID="$(json_field "$APPROVE_PROMOTION_RESPONSE" "data.targetSkillId")"
if [[ -n "$TARGET_SKILL_ID" && "$TARGET_SKILL_ID" != "None" && "$TARGET_SKILL_ID" != "null" ]]; then
pass "Approved promotion exposes target skill id"
else
fail "Approved promotion should expose target skill id"
fi
GLOBAL_VERSIONS_RESPONSE="$(curl -sS \
"$BASE_URL/api/web/skills/global/$SKILL_SLUG/versions")"
assert_code "Promoted global skill versions are visible" "$GLOBAL_VERSIONS_RESPONSE" "0"
if JSON_INPUT="$GLOBAL_VERSIONS_RESPONSE" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
raise SystemExit(0 if any(
item["version"] == "1.0.0"
and item["status"] == "PUBLISHED"
and item["downloadAvailable"]
for item in items
) else 1)
PY
then
pass "Promoted global version is published and downloadable"
else
fail "Promoted global version should be PUBLISHED with downloadAvailable=true"
fi
GLOBAL_BUNDLE="$WORK_DIR/global-skill.zip"
GLOBAL_DOWNLOAD_STATUS="$(curl -sS -L -o "$GLOBAL_BUNDLE" -w '%{http_code}' \
"$BASE_URL/api/web/skills/global/$SKILL_SLUG/versions/1.0.0/download")"
if [[ "$GLOBAL_DOWNLOAD_STATUS" == "200" ]]; then
pass "Promoted global version download returns HTTP 200"
else
fail "Promoted global version download should return HTTP 200 (got $GLOBAL_DOWNLOAD_STATUS)"
fi
if python3 - "$GLOBAL_BUNDLE" <<'PY'
import sys
import zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
content = archive.read("SKILL.md").decode("utf-8")
raise SystemExit(0 if "Body" in content.splitlines() else 1)
PY
then
pass "Promoted global bundle contains the source SKILL.md"
else
fail "Promoted global bundle should contain the source SKILL.md"
fi
echo
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -ne 0 ]]; then

View file

@ -13,11 +13,19 @@
<artifactId>skillhub-app</artifactId>
<properties>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
@ -121,6 +129,16 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View file

@ -8,12 +8,13 @@ import com.iflytek.skillhub.observability.MessageObservationSupport;
import com.iflytek.skillhub.storage.ObjectStorageService;
import com.iflytek.skillhub.stream.RedissonScanTaskProducer;
import com.iflytek.skillhub.stream.ScanTaskConsumer;
import java.time.Clock;
import java.time.Duration;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
@ -28,7 +29,7 @@ public class RedisStreamConfig {
@Value("${skillhub.security.stream.reclaim-enabled:true}")
private boolean reclaimEnabled;
@Value("${skillhub.security.stream.reclaim-min-idle:PT2M}")
@Value("${skillhub.security.stream.reclaim-min-idle:PT16M}")
private Duration reclaimMinIdle;
@Value("${skillhub.security.stream.reclaim-batch-size:20}")
@ -37,6 +38,12 @@ public class RedisStreamConfig {
@Value("${skillhub.security.stream.reclaim-interval:PT30S}")
private Duration reclaimInterval;
@Value("${skillhub.security.scanner.retry-max-attempts:3}")
private int maxRetryAttempts;
@Value("${skillhub.security.stream.max-unavailable-age:PT1H}")
private Duration maxUnavailableAge;
@Bean
public RedissonScanTaskProducer redisScanTaskProducer(
RedissonClient redissonClient,
@ -52,6 +59,7 @@ public class RedisStreamConfig {
SkillVersionRepository skillVersionRepository,
ScanTaskProducer scanTaskProducer,
ObjectStorageService objectStorageService,
Clock clock,
MessageObservationSupport messageObservationSupport) {
return new ScanTaskConsumer(
redissonClient,
@ -66,6 +74,9 @@ public class RedisStreamConfig {
reclaimMinIdle,
reclaimBatchSize,
reclaimInterval,
maxRetryAttempts,
maxUnavailableAge,
clock,
messageObservationSupport
);
}

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.config;
import com.iflytek.skillhub.auth.session.CorruptSessionRemover;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* Connects the authentication module's recovery boundary to Spring Session storage.
*/
@Configuration
public class SessionRecoveryConfig {
@Bean
CorruptSessionRemover corruptSessionRemover(
StringRedisTemplate redisTemplate,
@Value("${spring.session.redis.namespace:spring:session}") String namespace) {
String keyPrefix = namespace.endsWith(":") ? namespace : namespace + ":";
return sessionId -> redisTemplate.delete(List.of(
keyPrefix + "sessions:" + sessionId,
keyPrefix + "sessions:expires:" + sessionId
));
}
}

View file

@ -12,7 +12,7 @@ public class SkillScannerProperties {
private String healthPath = "/health";
private String scanPath = "/scan-upload";
private int connectTimeoutMs = 5000;
private int readTimeoutMs = 300000;
private int readTimeoutMs = 900000;
private int retryMaxAttempts = 3;
private String mode = "local";
private Analyzers analyzers = new Analyzers();

View file

@ -1,9 +1,7 @@
package com.iflytek.skillhub.config;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.ratelimit.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -24,11 +22,4 @@ public class WebMvcRateLimitConfig implements WebMvcConfigurer {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**");
}
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
// Keep MVC async timeouts above the SSE emitter timeout so EventSource
// connections are not forcibly torn down every few seconds.
configurer.setDefaultTimeout(SseEmitterManager.defaultTimeoutMillis());
}
}

View file

@ -0,0 +1,59 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillReviewModerationRequest;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillReviewAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/skill-reviews")
@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')")
public class AdminSkillReviewController extends BaseApiController {
private final SkillReviewAppService reviewAppService;
public AdminSkillReviewController(ApiResponseFactory responseFactory,
SkillReviewAppService reviewAppService) {
super(responseFactory);
this.reviewAppService = reviewAppService;
}
@PostMapping("/{reviewId}/hide")
public ApiResponse<SkillReviewResponse> hide(
@PathVariable Long reviewId,
@Valid @RequestBody(required = false) SkillReviewModerationRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", reviewAppService.hide(
reviewId,
principal.userId(),
request != null ? request.reason() : null,
AuditRequestContext.from(httpRequest)
));
}
@PostMapping("/{reviewId}/restore")
public ApiResponse<SkillReviewResponse> restore(
@PathVariable Long reviewId,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated", reviewAppService.restore(
reviewId,
principal.userId(),
AuditRequestContext.from(httpRequest)
));
}
}

View file

@ -8,7 +8,6 @@ import com.iflytek.skillhub.dto.*;
import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import java.util.Collections;
import java.util.Map;
import jakarta.validation.constraints.Max;
@ -16,10 +15,8 @@ import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@Validated
@ -27,16 +24,13 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
public class NotificationController extends BaseApiController {
private final NotificationService notificationService;
private final SseEmitterManager sseEmitterManager;
private final ObjectMapper objectMapper;
public NotificationController(NotificationService notificationService,
SseEmitterManager sseEmitterManager,
ObjectMapper objectMapper,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.notificationService = notificationService;
this.sseEmitterManager = sseEmitterManager;
this.objectMapper = objectMapper;
}
@ -79,11 +73,6 @@ public class NotificationController extends BaseApiController {
return ok("response.success.deleted", null);
}
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse(@RequestAttribute("userId") String userId) {
return sseEmitterManager.register(userId);
}
private NotificationResponse toResponse(Notification n) {
NotificationTarget target = resolveTarget(n);
return new NotificationResponse(

View file

@ -8,11 +8,13 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewActionRequest;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskRequest;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
@ -136,6 +138,38 @@ public class ReviewController extends BaseApiController {
return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId));
}
@GetMapping("/my-progress")
public ApiResponse<ReviewProgressPageResponse> listMyProgress(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
);
}
@GetMapping("/my-progress/{id}/attempts")
public ApiResponse<List<ReviewTaskResponse>> listMyAttempts(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId));
}
@GetMapping("/{id}/attempts")
public ApiResponse<List<ReviewTaskResponse>> listReviewAttempts(
@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false)
Map<Long, NamespaceRole> userNsRoles) {
return ok(
"response.success.read",
governanceWorkflowAppService.listReviewAttempts(id, userId, userNsRoles)
);
}
@GetMapping("/{id}")
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId,

View file

@ -19,9 +19,14 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SecurityAuditResponse;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SecurityScanRetryAppService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -41,19 +46,40 @@ public class SecurityAuditController extends BaseApiController {
private final SkillVersionRepository skillVersionRepository;
private final VisibilityChecker visibilityChecker;
private final ObjectMapper objectMapper;
private final SecurityScanRetryAppService securityScanRetryAppService;
public SecurityAuditController(SecurityAuditRepository securityAuditRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
VisibilityChecker visibilityChecker,
ApiResponseFactory responseFactory,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
SecurityScanRetryAppService securityScanRetryAppService) {
super(responseFactory);
this.securityAuditRepository = securityAuditRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.visibilityChecker = visibilityChecker;
this.objectMapper = objectMapper;
this.securityScanRetryAppService = securityScanRetryAppService;
}
@PostMapping("/retry")
public ApiResponse<SkillLifecycleMutationResponse> retrySecurityScan(
@PathVariable Long skillId,
@PathVariable Long versionId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest request) {
SkillLifecycleMutationResponse result = securityScanRetryAppService.retry(
skillId,
versionId,
principal.userId(),
principal.platformRoles(),
userNsRoles,
AuditRequestContext.from(request)
);
return ok("security_audit.retry.started", result);
}
@GetMapping
@ -121,6 +147,7 @@ public class SecurityAuditController extends BaseApiController {
audit.getFindingsCount(),
deserializeFindings(audit.getFindings()),
audit.getScanDurationSeconds(),
audit.getFailureReason(),
audit.getScannedAt(),
audit.getCreatedAt()
);

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReviewMeResponse;
import com.iflytek.skillhub.dto.SkillReviewRequest;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.service.SkillReviewAppService;
import jakarta.validation.Valid;
import java.util.Map;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillReviewController extends BaseApiController {
private final SkillReviewAppService reviewAppService;
public SkillReviewController(ApiResponseFactory responseFactory,
SkillReviewAppService reviewAppService) {
super(responseFactory);
this.reviewAppService = reviewAppService;
}
@GetMapping("/{skillId}/reviews")
public ApiResponse<PageResponse<SkillReviewResponse>> list(
@PathVariable Long skillId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.read", reviewAppService.list(
skillId,
principal != null ? principal.userId() : null,
namespaceRoles,
roles(principal),
page,
size
));
}
@GetMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> getMine(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.read", reviewAppService.getMine(
skillId, principal.userId(), namespaceRoles, roles(principal)));
}
@PutMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> upsert(
@PathVariable Long skillId,
@Valid @RequestBody SkillReviewRequest request,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.updated", reviewAppService.upsert(
skillId,
principal.userId(),
request.score(),
request.reviewText(),
namespaceRoles,
roles(principal)
));
}
@DeleteMapping("/{skillId}/reviews/me")
public ApiResponse<SkillReviewMeResponse> clear(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
return ok("response.success.updated", reviewAppService.clear(
skillId, principal.userId(), namespaceRoles, roles(principal)));
}
private Set<String> roles(PlatformPrincipal principal) {
return principal != null && principal.platformRoles() != null
? principal.platformRoles()
: Set.of();
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
import java.util.List;
/**
* Author-facing review progress page with search-scoped current-status totals.
*/
public record ReviewProgressPageResponse(
List<ReviewProgressResponse> items,
long total,
int page,
int size,
ReviewProgressStatusCounts statusCounts
) {}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
/**
* Author-facing summary for one skill version's review attempts.
*/
public record ReviewProgressResponse(
Long latestReviewTaskId,
Long skillId,
String namespace,
String skillSlug,
String skillVersion,
String latestStatus,
String latestReviewComment,
Instant latestSubmittedAt,
Instant latestReviewedAt,
long attemptCount
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
/**
* Current review-status totals for the author's grouped skill-version progress.
*/
public record ReviewProgressStatusCounts(
long pending,
long approved,
long rejected
) {}

View file

@ -16,6 +16,7 @@ public record SecurityAuditResponse(
Integer findingsCount,
List<SecurityFinding> findings,
Double scanDurationSeconds,
String failureReason,
Instant scannedAt,
Instant createdAt
) {

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record SkillReviewMeResponse(
boolean rated,
short score,
boolean reviewed,
Long reviewId,
String reviewText,
String status,
String moderationReason,
Instant createdAt,
Instant updatedAt
) {
public static SkillReviewMeResponse empty() {
return new SkillReviewMeResponse(false, (short) 0, false, null, null, null, null, null, null);
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Size;
public record SkillReviewModerationRequest(
@Size(max = 500) String reason
) {}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record SkillReviewRequest(
@NotNull @Min(1) @Max(5) Short score,
@NotBlank @Size(max = 2000) String reviewText
) {}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SkillReviewResponse(
Long id,
String userId,
String displayName,
String avatarUrl,
short score,
String reviewText,
String status,
boolean authoredByViewer,
String moderationReason,
Instant createdAt,
Instant updatedAt
) {}

View file

@ -17,6 +17,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.validation.FieldError;
@ -68,6 +69,15 @@ public class GlobalExceptionHandler {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handlePersistenceConflict(
RuntimeException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.CONFLICT, "error.request.conflict", request);
return ResponseEntity.status(HttpStatus.CONFLICT).body(
apiResponseFactory.error(409, "error.request.conflict"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String msg = ex.getBindingResult().getFieldErrors().stream()
@ -177,12 +187,6 @@ public class GlobalExceptionHandler {
@ExceptionHandler(AsyncRequestTimeoutException.class)
public ResponseEntity<?> handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) {
String path = request.getRequestURI();
if (path != null && path.endsWith("/sse")) {
logger.debug("SSE timeout [requestId={}, path={}]", requestIdAccessor.current(), path);
return ResponseEntity.noContent().build();
}
logHandledException(HttpStatus.REQUEST_TIMEOUT, "error.request.timeout", request);
return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body(
apiResponseFactory.error(408, "error.request.timeout"));

View file

@ -8,8 +8,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
@ -29,20 +27,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Set<String> SKIP_PREFIXES = Set.of(
"/actuator", "/favicon.ico", "/assets/"
);
private static final Set<String> SKIP_SUFFIXES = Set.of(
"/sse"
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
if (isNotificationSse(uri)) {
prepareSseResponse(response);
filterChain.doFilter(request, response);
return;
}
if (shouldSkip(uri)) {
filterChain.doFilter(request, response);
return;
@ -91,24 +80,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
return true;
}
}
for (String suffix : SKIP_SUFFIXES) {
if (uri.endsWith(suffix)) {
return true;
}
}
return false;
}
private boolean isNotificationSse(String uri) {
return uri != null && uri.endsWith("/notifications/sse");
}
private void prepareSseResponse(HttpServletResponse response) {
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform");
response.setHeader("X-Accel-Buffering", "no");
}
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;

View file

@ -10,7 +10,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import com.iflytek.skillhub.notification.service.NotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
@ -31,7 +31,7 @@ public class NotificationEventListener {
private final SkillVersionRepository skillVersionRepository;
private final NamespaceRepository namespaceRepository;
private final RecipientResolver recipientResolver;
private final NotificationDispatcher dispatcher;
private final NotificationService notificationService;
private final SkillSubscriptionService skillSubscriptionService;
private final ObjectMapper objectMapper;
private final SubscriptionRecipientEligibility subscriptionEligibility;
@ -40,7 +40,7 @@ public class NotificationEventListener {
SkillVersionRepository skillVersionRepository,
NamespaceRepository namespaceRepository,
RecipientResolver recipientResolver,
NotificationDispatcher dispatcher,
NotificationService notificationService,
SkillSubscriptionService skillSubscriptionService,
ObjectMapper objectMapper,
SubscriptionRecipientEligibility subscriptionEligibility) {
@ -48,7 +48,7 @@ public class NotificationEventListener {
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
this.recipientResolver = recipientResolver;
this.dispatcher = dispatcher;
this.notificationService = notificationService;
this.skillSubscriptionService = skillSubscriptionService;
this.objectMapper = objectMapper;
this.subscriptionEligibility = subscriptionEligibility;
@ -65,7 +65,7 @@ public class NotificationEventListener {
Map<String, Object> body = bodyWithSkill(skill);
versionLabel(event.versionId(), body);
String json = toJson(body);
dispatcher.dispatch(event.publisherId(), NotificationCategory.PUBLISH,
notificationService.create(event.publisherId(), NotificationCategory.PUBLISH,
"SKILL_PUBLISHED", title, json, "SKILL", event.skillId());
});
}
@ -88,7 +88,7 @@ public class NotificationEventListener {
if (subscriberId.equals(event.publisherId())) {
continue; // skip the publisher
}
dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH,
notificationService.create(subscriberId, NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", title, json, "SKILL", event.skillId());
}
});
@ -112,7 +112,7 @@ public class NotificationEventListener {
if (subscriberId.equals(event.actorUserId())) {
continue; // skip the actor
}
dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH,
notificationService.create(subscriberId, NotificationCategory.PUBLISH,
"SUBSCRIPTION_VERSION_YANKED", title, json, "SKILL", event.skillId());
}
});
@ -130,7 +130,7 @@ public class NotificationEventListener {
String json = toJson(body);
List<String> admins = recipientResolver.resolveNamespaceAdmins(event.namespaceId());
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.REVIEW,
notificationService.create(admin, NotificationCategory.REVIEW,
"REVIEW_SUBMITTED", title, json, "REVIEW", event.reviewId());
}
});
@ -147,7 +147,7 @@ public class NotificationEventListener {
String json = toJson(body);
List<String> admins = recipientResolver.resolvePlatformUserAdmins();
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.REVIEW,
notificationService.create(admin, NotificationCategory.REVIEW,
"PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId());
}
}
@ -162,7 +162,7 @@ public class NotificationEventListener {
body.put("reviewerId", event.reviewerId());
versionLabel(event.versionId(), body);
String json = toJson(body);
dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW,
notificationService.create(event.submitterId(), NotificationCategory.REVIEW,
"REVIEW_APPROVED", title, json, "SKILL", event.skillId());
});
}
@ -178,7 +178,7 @@ public class NotificationEventListener {
body.put("reason", event.reason());
versionLabel(event.versionId(), body);
String json = toJson(body);
dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW,
notificationService.create(event.submitterId(), NotificationCategory.REVIEW,
"REVIEW_REJECTED", title, json, "SKILL", event.skillId());
});
}
@ -195,7 +195,7 @@ public class NotificationEventListener {
String json = toJson(body);
List<String> admins = recipientResolver.resolvePlatformSkillAdmins();
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.PROMOTION,
notificationService.create(admin, NotificationCategory.PROMOTION,
"PROMOTION_SUBMITTED", title, json, "PROMOTION", event.promotionId());
}
});
@ -210,7 +210,7 @@ public class NotificationEventListener {
body.put("promotionId", event.promotionId());
body.put("reviewerId", event.reviewerId());
String json = toJson(body);
dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION,
notificationService.create(event.submitterId(), NotificationCategory.PROMOTION,
"PROMOTION_APPROVED", title, json, "SKILL", event.skillId());
});
}
@ -225,7 +225,7 @@ public class NotificationEventListener {
body.put("reviewerId", event.reviewerId());
body.put("reason", event.reason());
String json = toJson(body);
dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION,
notificationService.create(event.submitterId(), NotificationCategory.PROMOTION,
"PROMOTION_REJECTED", title, json, "SKILL", event.skillId());
});
}
@ -241,7 +241,7 @@ public class NotificationEventListener {
String json = toJson(body);
List<String> admins = recipientResolver.resolvePlatformSkillAdmins();
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.REPORT,
notificationService.create(admin, NotificationCategory.REPORT,
"REPORT_SUBMITTED", title, json, "REPORT", event.reportId());
}
});
@ -257,7 +257,7 @@ public class NotificationEventListener {
body.put("handlerId", event.handlerId());
body.put("action", event.action());
String json = toJson(body);
dispatcher.dispatch(event.reporterId(), NotificationCategory.REPORT,
notificationService.create(event.reporterId(), NotificationCategory.REPORT,
"REPORT_RESOLVED", title, json, "SKILL", event.skillId());
});
}

View file

@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
? Map.of()
: skillVersionRepository.findByIdIn(versionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList());
Set<Long> skillIds = new LinkedHashSet<>(distinct(
versionsById.values().stream().map(SkillVersion::getSkillId).toList()));
skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList()));
Map<Long, Skill> skillsById = skillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(skillIds).stream()
: skillRepository.findByIdIn(List.copyOf(skillIds)).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList());
Set<Long> namespaceIds = new LinkedHashSet<>(distinct(
skillsById.values().stream().map(Skill::getNamespaceId).toList()));
namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList()));
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
: namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
List<String> userIds = distinctStrings(tasks.stream()
.flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy()))
@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
}
private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) {
SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found");
Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found");
Long skillId = task.getSkillId() != null
? task.getSkillId()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
String skillVersion = task.getSkillVersion() != null
? task.getSkillVersion()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null;
return new ReviewTaskResponse(
@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
task.getSkillVersionId(),
namespace.getSlug(),
skill.getSlug(),
version.getVersion(),
skillVersion,
task.getStatus().name(),
task.getSubmittedBy(),
submittedBy != null ? submittedBy.getDisplayName() : null,

View file

@ -0,0 +1,176 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.ReviewProgressStatusCounts;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* PostgreSQL read-model query for review progress.
*
* <p>Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts,
* not individual review tasks. Window functions keep grouping, latest-attempt selection, counts,
* filtering, and pagination in the database instead of loading an author's full history.</p>
*/
@Repository
public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository {
private static final String RANKED_CTE = """
WITH ranked AS (
SELECT task.id,
task.skill_id,
task.namespace_id,
task.skill_version,
task.status,
task.review_comment,
task.submitted_at,
task.reviewed_at,
ROW_NUMBER() OVER (
PARTITION BY task.skill_id, task.skill_version
ORDER BY task.submitted_at DESC, task.id DESC
) AS attempt_rank,
COUNT(*) OVER (
PARTITION BY task.skill_id, task.skill_version
) AS attempt_count
FROM review_task task
WHERE task.submitted_by = :userId
), latest AS (
SELECT *
FROM ranked
WHERE attempt_rank = 1
)
""";
private static final String MY_PROGRESS_SQL = RANKED_CTE + """
SELECT latest.id,
latest.skill_id,
namespace.slug,
skill.slug,
latest.skill_version,
latest.status,
latest.review_comment,
latest.submitted_at,
latest.reviewed_at,
latest.attempt_count
FROM latest
JOIN skill ON skill.id = latest.skill_id
JOIN namespace ON namespace.id = latest.namespace_id
WHERE (
:query = ''
OR LOWER(skill.slug) LIKE :queryPattern
OR LOWER(namespace.slug) LIKE :queryPattern
)
AND (:status = '' OR latest.status = :status)
ORDER BY latest.submitted_at DESC, latest.id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
""";
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total,
COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count,
COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count,
COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count
FROM latest
JOIN skill ON skill.id = latest.skill_id
JOIN namespace ON namespace.id = latest.namespace_id
WHERE :query = ''
OR LOWER(skill.slug) LIKE :queryPattern
OR LOWER(namespace.slug) LIKE :queryPattern
""";
private final EntityManager entityManager;
public JpaReviewProgressQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
@Transactional(readOnly = true)
public ReviewProgressPageResponse findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
String statusName = status != null ? status.name() : "";
String queryPattern = "%" + normalizedQuery + "%";
Query nativeQuery = bindFilters(
entityManager.createNativeQuery(MY_PROGRESS_SQL),
userId,
statusName,
normalizedQuery,
queryPattern)
.setParameter("offset", (long) page * size)
.setParameter("size", size);
Query summaryQuery = bindFilters(
entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL),
userId,
statusName,
normalizedQuery,
queryPattern);
@SuppressWarnings("unchecked")
List<Object[]> rows = nativeQuery.getResultList();
List<ReviewProgressResponse> items = rows.stream().map(this::mapRow).toList();
Object[] summary = (Object[]) summaryQuery.getSingleResult();
long total = number(summary[0]).longValue();
ReviewProgressStatusCounts statusCounts = new ReviewProgressStatusCounts(
number(summary[1]).longValue(),
number(summary[2]).longValue(),
number(summary[3]).longValue()
);
return new ReviewProgressPageResponse(items, total, page, size, statusCounts);
}
private Query bindFilters(
Query query,
String userId,
String status,
String normalizedQuery,
String queryPattern) {
return query
.setParameter("userId", userId)
.setParameter("status", status)
.setParameter("query", normalizedQuery)
.setParameter("queryPattern", queryPattern);
}
private ReviewProgressResponse mapRow(Object[] row) {
return new ReviewProgressResponse(
number(row[0]).longValue(),
number(row[1]).longValue(),
(String) row[2],
(String) row[3],
(String) row[4],
String.valueOf(row[5]),
(String) row[6],
instant(row[7]),
instant(row[8]),
number(row[9]).longValue()
);
}
private Number number(Object value) {
if (value instanceof Number number) {
return number;
}
throw new IllegalStateException("Expected numeric review progress value, got " + value);
}
private Instant instant(Object value) {
if (value == null) return null;
if (value instanceof Instant instant) return instant;
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName());
}
}

View file

@ -0,0 +1,68 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository {
private final SkillRatingRepository ratingRepository;
private final UserAccountRepository userAccountRepository;
public JpaSkillReviewQueryRepository(SkillRatingRepository ratingRepository,
UserAccountRepository userAccountRepository) {
this.ratingRepository = ratingRepository;
this.userAccountRepository = userAccountRepository;
}
@Override
public Page<SkillReviewResponse> list(Long skillId,
String viewerId,
boolean includeHidden,
Pageable pageable) {
Page<SkillRating> reviews = includeHidden
? ratingRepository.findReviewsBySkillId(skillId, pageable)
: ratingRepository.findVisibleReviewsBySkillId(skillId, pageable);
List<String> authorIds = reviews.getContent().stream()
.map(SkillRating::getUserId)
.distinct()
.toList();
Map<String, UserAccount> authors = userAccountRepository.findByIdIn(authorIds).stream()
.collect(Collectors.toMap(UserAccount::getId, Function.identity()));
return reviews.map(review -> toResponse(
review,
authors.get(review.getUserId()),
viewerId,
includeHidden
));
}
private SkillReviewResponse toResponse(SkillRating review,
UserAccount author,
String viewerId,
boolean includeModerationDetails) {
return new SkillReviewResponse(
review.getId(),
includeModerationDetails ? review.getUserId() : null,
author != null ? author.getDisplayName() : review.getUserId(),
author != null ? author.getAvatarUrl() : null,
review.getScore(),
review.getReviewText(),
review.getReviewStatus().name(),
review.getUserId().equals(viewerId),
includeModerationDetails ? review.getModerationReason() : null,
review.getCreatedAt(),
review.getUpdatedAt()
);
}
}

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
/**
* Query seam for author-facing review progress grouped by skill version.
*/
public interface ReviewProgressQueryRepository {
ReviewProgressPageResponse findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size
);
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface SkillReviewQueryRepository {
Page<SkillReviewResponse> list(Long skillId, String viewerId, boolean includeHidden, Pageable pageable);
}

View file

@ -8,10 +8,12 @@ import com.iflytek.skillhub.dto.NamespaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
@ -93,6 +95,26 @@ public class GovernanceWorkflowAppService {
return reviewPortalAppService.listMySubmissions(page, size, userId);
}
public ReviewProgressPageResponse listMyReviewProgress(
String status,
String query,
int page,
int size,
String userId) {
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
}
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {
return reviewPortalAppService.listMyAttempts(reviewTaskId, userId);
}
public List<ReviewTaskResponse> listReviewAttempts(
Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
return reviewPortalAppService.listReviewAttempts(reviewTaskId, userId, userNsRoles);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -13,9 +13,11 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -34,6 +36,7 @@ public class ReviewPortalAppService {
private final ReviewTaskRepository reviewTaskRepository;
private final NamespaceRepository namespaceRepository;
private final GovernanceQueryRepository governanceQueryRepository;
private final ReviewProgressQueryRepository reviewProgressQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
@ -42,6 +45,7 @@ public class ReviewPortalAppService {
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
ReviewProgressQueryRepository reviewProgressQueryRepository,
RbacService rbacService,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
@ -49,6 +53,7 @@ public class ReviewPortalAppService {
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.reviewProgressQueryRepository = reviewProgressQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
@ -212,6 +217,63 @@ public class ReviewPortalAppService {
));
}
public ReviewProgressPageResponse listMyProgress(
String status,
String query,
int page,
int size,
String userId) {
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
? null
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
return reviewProgressQueryRepository.findMyProgress(
userId,
reviewStatus,
query != null ? query : "",
safePage,
safeSize
);
}
public List<ReviewTaskResponse> listMyAttempts(Long reviewTaskId, String userId) {
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
if (!anchor.getSubmittedBy().equals(userId)) {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
userId, anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
}
public List<ReviewTaskResponse> listReviewAttempts(
Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
Namespace namespace = namespaceRepository.findById(anchor.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException(
"namespace.not_found", anchor.getNamespaceId()));
if (!reviewService.canReviewNamespace(
anchor,
userId,
namespace.getType(),
normalizeRoles(userNsRoles),
platformRoles(userId))) {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -0,0 +1,131 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.security.ScanTask;
import com.iflytek.skillhub.domain.security.ScannerType;
import com.iflytek.skillhub.domain.security.SecurityAuditRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SecurityScanRetryAppService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SecurityAuditRepository securityAuditRepository;
private final SecurityScanService securityScanService;
private final ObjectStorageService objectStorageService;
private final AuditLogService auditLogService;
public SecurityScanRetryAppService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SecurityAuditRepository securityAuditRepository,
SecurityScanService securityScanService,
ObjectStorageService objectStorageService,
AuditLogService auditLogService) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.securityAuditRepository = securityAuditRepository;
this.securityScanService = securityScanService;
this.objectStorageService = objectStorageService;
this.auditLogService = auditLogService;
}
@Transactional
public SkillLifecycleMutationResponse retry(Long skillId,
Long versionId,
String userId,
Set<String> platformRoles,
Map<Long, NamespaceRole> namespaceRoles,
AuditRequestContext auditContext) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
authorize(skill, userId, platformRoles, namespaceRoles);
SkillVersionStatus observedStatus = skillVersionRepository.findStatusByIdAndSkillId(versionId, skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
if (observedStatus != SkillVersionStatus.SCAN_FAILED
&& observedStatus != SkillVersionStatus.SCANNING) {
throw new DomainBadRequestException("error.security.scan.retry.status", observedStatus);
}
if (!securityScanService.isEnabled()) {
throw new DomainBadRequestException("error.security.scan.retry.disabled");
}
String bundleKey = bundleKey(skillId, versionId);
if (observedStatus == SkillVersionStatus.SCAN_FAILED
&& !objectStorageService.exists(bundleKey)) {
throw new DomainBadRequestException("error.security.scan.retry.bundleMissing");
}
SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId)
.filter(candidate -> candidate.getSkillId().equals(skillId))
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
if (version.getStatus() == SkillVersionStatus.SCANNING && hasActiveAttempt(versionId)) {
return response(skillId, versionId);
}
if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) {
throw new DomainBadRequestException("error.security.scan.retry.status", version.getStatus());
}
ScanTask task = securityScanService.retryStoredBundleScan(version, bundleKey, userId);
auditLogService.record(
userId,
"RETRY_SECURITY_SCAN",
"SKILL_VERSION",
versionId,
null,
auditContext.clientIp(),
auditContext.userAgent(),
AuditDetail.of("taskId", task.taskId(), "version", version.getVersion())
);
return response(skillId, versionId);
}
private void authorize(Skill skill,
String userId,
Set<String> platformRoles,
Map<Long, NamespaceRole> namespaceRoles) {
Set<String> roles = platformRoles != null ? platformRoles : Set.of();
Map<Long, NamespaceRole> memberships = namespaceRoles != null ? namespaceRoles : Map.of();
NamespaceRole namespaceRole = memberships.get(skill.getNamespaceId());
boolean allowed = skill.getOwnerId().equals(userId)
|| namespaceRole == NamespaceRole.OWNER
|| namespaceRole == NamespaceRole.ADMIN
|| roles.contains("SUPER_ADMIN")
|| roles.contains("SKILL_ADMIN");
if (!allowed) {
throw new DomainForbiddenException("error.forbidden");
}
}
private boolean hasActiveAttempt(Long versionId) {
return securityAuditRepository
.findLatestActiveByVersionIdAndScannerType(versionId, ScannerType.SKILL_SCANNER)
.filter(audit -> audit.getScannedAt() == null)
.isPresent();
}
private String bundleKey(Long skillId, Long versionId) {
return String.format("packages/%d/%d/bundle.zip", skillId, versionId);
}
private SkillLifecycleMutationResponse response(Long skillId, Long versionId) {
return new SkillLifecycleMutationResponse(skillId, versionId, "RETRY_SECURITY_SCAN", "SCANNING");
}
}

View file

@ -0,0 +1,206 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillReviewMeResponse;
import com.iflytek.skillhub.dto.SkillReviewResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.SkillReviewQueryRepository;
import java.util.Map;
import java.util.Set;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SkillReviewAppService {
private static final int MAX_PAGE_SIZE = 100;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final VisibilityChecker visibilityChecker;
private final SkillRatingService ratingService;
private final SkillReviewQueryRepository queryRepository;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public SkillReviewAppService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
VisibilityChecker visibilityChecker,
SkillRatingService ratingService,
SkillReviewQueryRepository queryRepository,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.visibilityChecker = visibilityChecker;
this.ratingService = ratingService;
this.queryRepository = queryRepository;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public PageResponse<SkillReviewResponse> list(Long skillId,
String viewerId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int page,
int size) {
requireVisibleSkill(skillId, viewerId, namespaceRoles, platformRoles);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new DomainBadRequestException("error.pagination.invalid", MAX_PAGE_SIZE);
}
boolean includeHidden = isReviewModerator(platformRoles);
return PageResponse.from(queryRepository.list(
skillId,
viewerId,
includeHidden,
PageRequest.of(page, size)
));
}
public SkillReviewMeResponse getMine(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return ratingService.getUserFeedback(skillId, userId)
.map(this::toMine)
.orElseGet(SkillReviewMeResponse::empty);
}
public SkillReviewMeResponse upsert(Long skillId,
String userId,
short score,
String reviewText,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles);
return toMine(ratingService.upsertReview(skillId, userId, score, reviewText));
}
public SkillReviewMeResponse clear(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return toMine(ratingService.clearReview(skillId, userId));
}
@Transactional
public SkillReviewResponse hide(Long reviewId,
String moderatorId,
String reason,
AuditRequestContext auditContext) {
SkillRating review = ratingService.hideReview(reviewId, moderatorId, reason);
recordModerationAudit("SKILL_REVIEW_HIDE", review, moderatorId, reason, auditContext);
return toModerationResponse(review);
}
@Transactional
public SkillReviewResponse restore(Long reviewId,
String moderatorId,
AuditRequestContext auditContext) {
SkillRating review = ratingService.restoreReview(reviewId, moderatorId);
recordModerationAudit("SKILL_REVIEW_RESTORE", review, moderatorId, null, auditContext);
return toModerationResponse(review);
}
private Skill requireVisibleSkill(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId));
if (!visibilityChecker.canAccess(
skill,
userId,
namespaceRoles != null ? namespaceRoles : Map.of(),
platformRoles != null ? platformRoles : Set.of())) {
throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug());
}
return skill;
}
private Skill requireInteractableSkill(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
Skill skill = requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles);
boolean published = skill.getLatestVersionId() != null
&& skillVersionRepository.findById(skill.getLatestVersionId())
.map(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
.orElse(false);
if (skill.getStatus() != SkillStatus.ACTIVE || !published) {
throw new DomainBadRequestException("error.skillReview.notInteractable");
}
return skill;
}
private SkillReviewMeResponse toMine(SkillRating review) {
return new SkillReviewMeResponse(
true,
review.getScore(),
review.hasReview(),
review.hasReview() ? review.getId() : null,
review.getReviewText(),
review.hasReview() ? review.getReviewStatus().name() : null,
review.getModerationReason(),
review.getCreatedAt(),
review.getUpdatedAt()
);
}
private SkillReviewResponse toModerationResponse(SkillRating review) {
return new SkillReviewResponse(
review.getId(),
review.getUserId(),
review.getUserId(),
null,
review.getScore(),
review.getReviewText(),
review.getReviewStatus().name(),
false,
review.getModerationReason(),
review.getCreatedAt(),
review.getUpdatedAt()
);
}
private boolean isReviewModerator(Set<String> platformRoles) {
return platformRoles != null
&& (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"));
}
private void recordModerationAudit(String action,
SkillRating review,
String moderatorId,
String reason,
AuditRequestContext context) {
auditLogService.record(
moderatorId,
action,
"SKILL_REVIEW",
review.getId(),
requestIdAccessor.current(),
context != null ? context.clientIp() : null,
context != null ? context.userAgent() : null,
AuditDetail.builder()
.put("skillId", review.getSkillId())
.put("reason", reason)
.build()
);
}
}

View file

@ -28,8 +28,8 @@ public abstract class AbstractStreamConsumer<T> {
protected final Logger log = LoggerFactory.getLogger(getClass());
private static final String FIELD_RETRY_COUNT = "retryCount";
private static final int MAX_RETRY_COUNT = 3;
private static final int READ_BATCH_SIZE = 10;
private static final int DEFAULT_MAX_RETRY_COUNT = 3;
private static final int DEFAULT_READ_BATCH_SIZE = 10;
private static final Duration POLL_TIMEOUT = Duration.ofSeconds(2);
private final RedissonClient redissonClient;
@ -170,7 +170,7 @@ public abstract class AbstractStreamConsumer<T> {
groupName,
consumerName,
StreamReadGroupArgs.neverDelivered()
.count(READ_BATCH_SIZE)
.count(readBatchSize())
.timeout(POLL_TIMEOUT)
);
processMessages(messages);
@ -237,21 +237,27 @@ public abstract class AbstractStreamConsumer<T> {
acknowledge(messageId);
} catch (Exception e) {
messageObservationSupport.recordCurrentError(e);
handleFailure(payload, retryCount, e);
acknowledge(messageId);
if (shouldDeferFailure(payload, e)) {
markDeferred(payload, e);
} else {
handleFailure(payload, retryCount, e);
acknowledge(messageId);
}
}
}
private void handleFailure(T payload, int retryCount, Exception e) {
if (retryCount < MAX_RETRY_COUNT) {
if (shouldRetry(payload, e, retryCount)) {
// Retry publication remains inside the current consumer scope, so the new producer
// span and message carrier continue the original trace.
retryMessage(payload, retryCount + 1);
return;
}
markFailed(payload, truncateError(
taskDisplayName() + " failed (retried " + retryCount + " times): " + e.getMessage()
));
markFailed(payload, truncateError(finalFailureReason(payload, e, retryCount)));
}
protected String finalFailureReason(T payload, Exception error, int retryCount) {
return taskDisplayName() + " failed (retried " + retryCount + " times): " + error.getMessage();
}
protected int parseRetryCount(Map<String, String> data) {
@ -274,7 +280,32 @@ public abstract class AbstractStreamConsumer<T> {
}
protected void acknowledge(StreamMessageId messageId) {
stream().ack(groupName, messageId);
if (stream().ack(groupName, messageId) > 0) {
// This stream has one consumer group. Removing acknowledged entries prevents
// completed scan tasks from growing the Redis stream without bound.
stream().remove(messageId);
}
}
protected int readBatchSize() {
return DEFAULT_READ_BATCH_SIZE;
}
protected int maxRetryCount() {
return DEFAULT_MAX_RETRY_COUNT;
}
protected boolean shouldRetry(T payload, Exception error, int retryCount) {
return retryCount < maxRetryCount();
}
protected boolean shouldDeferFailure(T payload, Exception error) {
return false;
}
protected void markDeferred(T payload, Exception error) {
log.warn("Deferring {} task without acknowledging its stream entry: {}",
taskDisplayName(), payloadIdentifier(payload), error);
}
protected final RStream<String, String> stream() {

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.observability.MessageObservationSupport;
import com.iflytek.skillhub.storage.ObjectStorageService;
import com.iflytek.skillhub.infra.scanner.SecurityScanException;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
@ -20,12 +21,18 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.ScanTaskPayload> {
private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize();
private static final Duration DEFAULT_MAX_UNAVAILABLE_AGE = Duration.ofHours(1);
private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5);
private final RedissonClient redissonClient;
private final SecurityScanner securityScanner;
@ -33,6 +40,9 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
private final SkillVersionRepository skillVersionRepository;
private final ScanTaskProducer scanTaskProducer;
private final ObjectStorageService objectStorageService;
private final int maxRetryAttempts;
private final Duration maxUnavailableAge;
private final Clock clock;
public ScanTaskConsumer(RedissonClient redissonClient,
String streamKey,
@ -50,6 +60,9 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
this.skillVersionRepository = skillVersionRepository;
this.scanTaskProducer = scanTaskProducer;
this.objectStorageService = objectStorageService;
this.maxRetryAttempts = 3;
this.maxUnavailableAge = DEFAULT_MAX_UNAVAILABLE_AGE;
this.clock = Clock.systemUTC();
}
public ScanTaskConsumer(RedissonClient redissonClient,
@ -64,6 +77,9 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
Duration reclaimMinIdle,
int reclaimBatchSize,
Duration reclaimInterval,
int maxRetryAttempts,
Duration maxUnavailableAge,
Clock clock,
MessageObservationSupport messageObservationSupport) {
super(
redissonClient,
@ -81,6 +97,55 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
this.skillVersionRepository = skillVersionRepository;
this.scanTaskProducer = scanTaskProducer;
this.objectStorageService = objectStorageService;
this.maxRetryAttempts = maxRetryAttempts;
if (maxUnavailableAge == null || maxUnavailableAge.isZero() || maxUnavailableAge.isNegative()) {
throw new IllegalArgumentException("maxUnavailableAge must be positive");
}
this.maxUnavailableAge = maxUnavailableAge;
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
protected int readBatchSize() {
return 1;
}
@Override
protected int maxRetryCount() {
return maxRetryAttempts;
}
@Override
protected boolean shouldDeferFailure(ScanTaskPayload payload, Exception error) {
return error instanceof ConcurrentScanInProgressException
|| (isScannerUnavailable(error) && !hasUnavailableRecoveryExpired(payload));
}
@Override
protected boolean shouldRetry(ScanTaskPayload payload, Exception error, int retryCount) {
if (isScannerUnavailable(error) && hasUnavailableRecoveryExpired(payload)) {
return false;
}
return super.shouldRetry(payload, error, retryCount);
}
@Override
protected String finalFailureReason(ScanTaskPayload payload, Exception error, int retryCount) {
log.error("Security scan failed after retries: taskId={}, versionId={}, scanner={}, retryCount={}",
payload.taskId(), payload.versionId(), payload.scannerType(), retryCount, error);
if (isScannerUnavailable(error) && hasUnavailableRecoveryExpired(payload)) {
return "Security scanner did not recover before the configured timeout. "
+ "Retry after scanner availability is restored.";
}
return "Security scan failed after automatic retries. Retry the scan or contact an administrator.";
}
@Override
protected void markDeferred(ScanTaskPayload payload, Exception error) {
cleanupRetryTempPath(payload);
log.warn("Scanner unavailable; keeping task pending for later recovery: taskId={}, versionId={}, "
+ "taskAge={}, maxUnavailableAge={}, reason={}",
payload.taskId(), payload.versionId(), taskAge(payload), maxUnavailableAge, error.getMessage());
}
@Override
@ -108,7 +173,8 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
blankToNull(data.get("skillPath")),
blankToNull(data.get("bundleKey")),
scannerType,
parseRetryCount(data)
parseRetryCount(data),
parseCreatedAtMillis(messageId, data.get("createdAtMillis"))
);
} catch (NumberFormatException e) {
return null;
@ -166,7 +232,8 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
SecurityScanRequest request = new SecurityScanRequest(
payload.taskId(), payload.versionId(), skillPath, Map.of());
SecurityScanResponse response = securityScanner.scan(request);
securityScanService.processScanResult(payload.versionId(), payload.scannerType(), response);
securityScanService.processScanResult(
payload.taskId(), payload.versionId(), payload.scannerType(), response);
}
private static final class ConcurrentScanInProgressException extends RuntimeException {
@ -182,24 +249,24 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
@Override
protected void markFailed(ScanTaskPayload payload, String error) {
log.error("Security scan task failed permanently: taskId={}, versionId={}, scanner={}, source={}, error={}",
log.error("Security scan task failed permanently: taskId={}, versionId={}, scanner={}, source={}, "
+ "taskAge={}, maxUnavailableAge={}, error={}",
payload.taskId(),
payload.versionId(),
payload.scannerType(),
payload.sourceDescription(),
taskAge(payload),
maxUnavailableAge,
error);
try {
skillVersionRepository.findById(payload.versionId())
.filter(version -> version.getStatus() == SkillVersionStatus.SCANNING)
.ifPresent(version -> {
version.setStatus(SkillVersionStatus.SCAN_FAILED);
skillVersionRepository.save(version);
});
securityScanService.processScanFailure(
payload.taskId(), payload.versionId(), payload.scannerType(), error);
} finally {
cleanupTempPath(payload.cleanupPath());
}
}
@Override
protected void retryMessage(ScanTaskPayload payload, int retryCount) {
log.warn("Retrying security scan task: taskId={}, versionId={}, scanner={}, nextRetryCount={}, source={}",
@ -287,6 +354,55 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
return value == null || value.isBlank() ? null : value;
}
private boolean isScannerUnavailable(Exception error) {
return error instanceof SecurityScanException scanError && scanError.isScannerUnavailable();
}
private boolean hasUnavailableRecoveryExpired(ScanTaskPayload payload) {
Instant now = clock.instant();
long createdAtMillis = payload.createdAtMillis();
if (createdAtMillis <= 0 || createdAtMillis > now.plus(MAX_CLOCK_SKEW).toEpochMilli()) {
return true;
}
try {
return !Instant.ofEpochMilli(createdAtMillis).plus(maxUnavailableAge).isAfter(now);
} catch (DateTimeException | ArithmeticException ignored) {
return true;
}
}
private Duration taskAge(ScanTaskPayload payload) {
try {
Duration age = Duration.between(Instant.ofEpochMilli(payload.createdAtMillis()), clock.instant());
return age.isNegative() ? Duration.ZERO : age;
} catch (DateTimeException | ArithmeticException ignored) {
return maxUnavailableAge;
}
}
private long parseCreatedAtMillis(String messageId, String value) {
Long createdAt = parsePositiveLong(value);
if (createdAt != null) {
return createdAt;
}
int separator = messageId.indexOf('-');
String redisTimestamp = separator >= 0 ? messageId.substring(0, separator) : messageId;
Long fallback = parsePositiveLong(redisTimestamp);
return fallback != null ? fallback : 0L;
}
private Long parsePositiveLong(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
long parsed = Long.parseLong(value);
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
protected static final class ScanTaskPayload {
private final String taskId;
private final Long versionId;
@ -294,11 +410,12 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
private final String bundleKey;
private final ScannerType scannerType;
private final int retryCount;
private final long createdAtMillis;
private String workingSkillPath;
private boolean cleanupEnabled = true;
protected ScanTaskPayload(String taskId, Long versionId, String skillPath, String bundleKey, ScannerType scannerType) {
this(taskId, versionId, skillPath, bundleKey, scannerType, 0);
this(taskId, versionId, skillPath, bundleKey, scannerType, 0, System.currentTimeMillis());
}
protected ScanTaskPayload(String taskId,
@ -307,12 +424,23 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
String bundleKey,
ScannerType scannerType,
int retryCount) {
this(taskId, versionId, skillPath, bundleKey, scannerType, retryCount, System.currentTimeMillis());
}
protected ScanTaskPayload(String taskId,
Long versionId,
String skillPath,
String bundleKey,
ScannerType scannerType,
int retryCount,
long createdAtMillis) {
this.taskId = taskId;
this.versionId = versionId;
this.skillPath = skillPath;
this.bundleKey = bundleKey;
this.scannerType = scannerType;
this.retryCount = retryCount;
this.createdAtMillis = createdAtMillis;
}
protected String taskId() {
@ -339,6 +467,10 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
return retryCount;
}
protected long createdAtMillis() {
return createdAtMillis;
}
protected void markWorkingSkillPath(String workingSkillPath) {
this.workingSkillPath = workingSkillPath;
}

View file

@ -41,7 +41,7 @@ skillhub:
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:upload}
stream:
reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true}
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M}
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT16M}
reclaim-batch-size: ${SKILLHUB_SCAN_STREAM_RECLAIM_BATCH_SIZE:20}
reclaim-interval: ${SKILLHUB_SCAN_STREAM_RECLAIM_INTERVAL:PT30S}
bootstrap:

View file

@ -209,7 +209,9 @@ skillhub:
scan-path: /scan-upload
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
connect-timeout-ms: ${SKILLHUB_SECURITY_SCANNER_CONNECT_TIMEOUT:5000}
read-timeout-ms: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:300000}
# Keep this above the longest expected scan. Availability failures remain pending
# and are reclaimed later instead of moving the skill to SCAN_FAILED.
read-timeout-ms: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:900000}
retry-max-attempts: ${SKILLHUB_SECURITY_SCANNER_RETRY_MAX:3}
analyzers:
behavioral: ${SKILLHUB_SCANNER_USE_BEHAVIORAL:true}
@ -226,10 +228,13 @@ skillhub:
custom-policy-path: ${SKILLHUB_SCANNER_CUSTOM_POLICY_PATH:}
fail-on-severity: ${SKILLHUB_SCANNER_FAIL_ON_SEVERITY:high}
stream:
# Keep temporary scanner outages recoverable, but do not retain Redis Pending entries forever.
max-unavailable-age: ${SKILLHUB_SECURITY_STREAM_MAX_UNAVAILABLE_AGE:PT1H}
key: ${SKILLHUB_SCAN_STREAM_KEY:skillhub:scan:requests}
group: ${SKILLHUB_SCAN_STREAM_GROUP:skillhub-scanners}
reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true}
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M}
# Must exceed scanner read-timeout so an active task is not reclaimed prematurely.
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT16M}
reclaim-batch-size: ${SKILLHUB_SCAN_STREAM_RECLAIM_BATCH_SIZE:20}
reclaim-interval: ${SKILLHUB_SCAN_STREAM_RECLAIM_INTERVAL:PT30S}
bootstrap:

View file

@ -0,0 +1,27 @@
ALTER TABLE review_task
ADD COLUMN skill_id BIGINT,
ADD COLUMN skill_version VARCHAR(64);
UPDATE review_task task
SET skill_id = version.skill_id,
skill_version = version.version
FROM skill_version version
WHERE task.skill_version_id = version.id;
ALTER TABLE review_task
ALTER COLUMN skill_id SET NOT NULL,
ALTER COLUMN skill_version SET NOT NULL,
ALTER COLUMN skill_version_id DROP NOT NULL;
ALTER TABLE review_task
DROP CONSTRAINT review_task_skill_version_id_fkey,
ADD CONSTRAINT fk_review_task_skill_version
FOREIGN KEY (skill_version_id) REFERENCES skill_version(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_review_task_skill
FOREIGN KEY (skill_id) REFERENCES skill(id);
CREATE INDEX idx_review_task_submitter_submitted
ON review_task(submitted_by, submitted_at DESC, id DESC);
CREATE INDEX idx_review_task_skill_version_attempts
ON review_task(skill_id, skill_version, submitted_at DESC, id DESC);

View file

@ -0,0 +1,15 @@
ALTER TABLE skill_rating
ADD COLUMN review_text VARCHAR(2000),
ADD COLUMN review_status VARCHAR(16) NOT NULL DEFAULT 'VISIBLE',
ADD COLUMN moderated_by VARCHAR(128) REFERENCES user_account(id),
ADD COLUMN moderated_at TIMESTAMPTZ,
ADD COLUMN moderation_reason VARCHAR(500),
ADD COLUMN lock_version BIGINT NOT NULL DEFAULT 0,
ADD CONSTRAINT chk_skill_rating_review_status
CHECK (review_status IN ('VISIBLE', 'HIDDEN'));
CREATE INDEX idx_skill_rating_visible_reviews
ON skill_rating(skill_id, updated_at DESC, id DESC)
WHERE review_status = 'VISIBLE'
AND review_text IS NOT NULL
AND BTRIM(review_text) <> '';

View file

@ -0,0 +1,2 @@
ALTER TABLE security_audit
ADD COLUMN failure_reason VARCHAR(1000);

View file

@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0}
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills
error.security.scan.retry.status=Only a failed security scan can be retried (current status: {0})
error.security.scan.retry.disabled=Security scanning is disabled; enable it before retrying
error.security.scan.retry.bundleMissing=The stored package is unavailable; upload the skill again to retry scanning
security_audit.retry.started=Security scan retry started
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}
review.withdraw.not_submitter=Only the submitter can withdraw this review
@ -187,3 +191,10 @@ promotion.sort.field.invalid=Unsupported promotion sort field: {0}
promotion.sort.direction.invalid=Unsupported promotion sort direction: {0}
promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting
error.skill.subscription.noPermission=You do not have permission to subscribe to this skill.
error.skillReview.notFound=Skill review not found
error.skillReview.text.required=Review text is required
error.skillReview.text.tooLong=Review text must not exceed {0} characters
error.skillReview.reason.tooLong=Moderation reason must not exceed {0} characters
error.pagination.invalid=Page must be non-negative and size must be between 1 and {0}
error.request.conflict=The data changed while this request was being processed. Refresh and try again.
error.skillReview.notInteractable=Reviews are available only for published skills

View file

@ -99,6 +99,10 @@ error.skill.publish.skillMd.notFound=SKILL.md не найден
error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0}
error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0}
error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности
error.security.scan.retry.status=Повторить можно только неудачное сканирование безопасности (текущий статус: {0})
error.security.scan.retry.disabled=Сканер безопасности отключён; включите его перед повторной попыткой
error.security.scan.retry.bundleMissing=Сохранённый пакет недоступен; загрузите скилл заново для повторного сканирования
security_audit.retry.started=Повторное сканирование безопасности запущено
error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0}
review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0}
review.withdraw.not_submitter=Отозвать это ревью может только отправитель
@ -177,3 +181,10 @@ promotion.status.invalid=Неподдерживаемый статус прод
promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0}
promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0}
promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью
error.skillReview.notFound=Отзыв о скилле не найден
error.skillReview.text.required=Текст отзыва обязателен
error.skillReview.text.tooLong=Текст отзыва не должен превышать {0} символов
error.skillReview.reason.tooLong=Причина модерации не должна превышать {0} символов
error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0}
error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку.
error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков

View file

@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0}
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器
error.security.scan.retry.status=只有安全扫描失败的版本才能重试(当前状态:{0}
error.security.scan.retry.disabled=安全扫描器未启用,请启用后再重试
error.security.scan.retry.bundleMissing=原技能包已不存在,请重新上传技能后再扫描
security_audit.retry.started=已重新发起安全扫描
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}
review.withdraw.not_submitter=只有提交人本人可以撤销此次审核
@ -187,3 +191,10 @@ promotion.sort.field.invalid=不支持的提升审核排序字段:{0}
promotion.sort.direction.invalid=不支持的提升审核排序方向:{0}
promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序
error.skill.subscription.noPermission=您没有订阅此技能的权限。
error.skillReview.notFound=未找到技能评价
error.skillReview.text.required=评价内容不能为空
error.skillReview.text.tooLong=评价内容不能超过 {0} 个字符
error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符
error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间
error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试
error.skillReview.notInteractable=仅已发布的技能可以评价

View file

@ -0,0 +1,25 @@
package com.iflytek.skillhub.config;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
class SessionRecoveryConfigTest {
@Test
void corruptSessionRemover_deletesSessionKeysWithoutReadingTheCorruptValue() {
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
var remover = new SessionRecoveryConfig()
.corruptSessionRemover(redisTemplate, "skillhub:session");
remover.remove("broken-session");
verify(redisTemplate).delete(List.of(
"skillhub:session:sessions:broken-session",
"skillhub:session:sessions:expires:broken-session"
));
}
}

Some files were not shown because too many files have changed in this diff Show more