mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
Merge remote-tracking branch 'origin/main' into codex/maintain-pr788-20260904
# Conflicts: # web/src/pages/skill-detail.tsx
This commit is contained in:
commit
d0d43bbf45
244 changed files with 11962 additions and 2038 deletions
17
README.md
17
README.md
|
|
@ -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)**
|
||||
|
|
|
|||
17
README_zh.md
17
README_zh.md
|
|
@ -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 slug,SkillHub 会将其解析为
|
||||
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)**
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
cli/bun.lock
14
cli/bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
136
cli/src/commands/upgrade.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
82
cli/src/services/installed-skill-metadata.ts
Normal file
82
cli/src/services/installed-skill-metadata.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
79
cli/src/services/skill-target-lock.ts
Normal file
79
cli/src/services/skill-target-lock.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
11
cli/src/services/skill-version-order.ts
Normal file
11
cli/src/services/skill-version-order.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
409
cli/src/services/upgrade-service.ts
Normal file
409
cli/src/services/upgrade-service.ts
Normal 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(/\/+$/, '')
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
39
cli/test/helpers/target-lock-worker.ts
Normal file
39
cli/test/helpers/target-lock-worker.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ describe('install command — P0', () => {
|
|||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
versionId: 1,
|
||||
fingerprint: 'abc123',
|
||||
zipBytes: makeSkillZip()
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
727
cli/test/integration/upgrade-command.test.ts
Normal file
727
cli/test/integration/upgrade-command.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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-'))
|
||||
|
||||
|
|
|
|||
134
cli/test/unit/services/skill-target-lock.test.ts
Normal file
134
cli/test/unit/services/skill-target-lock.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -252,6 +252,10 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确
|
|||
→ 异步重算 skill.rating_avg 和 rating_count(SELECT AVG + Redis 分布式锁防重复重算)
|
||||
```
|
||||
|
||||
文字评价复用同一条 `skill_rating` 记录:用户提交 `score + review_text` 时同步更新评分并触发
|
||||
`SkillRatedEvent`;删除评价只清空文字,保留星级评分。公开列表仅返回 `VISIBLE` 评价;
|
||||
`SKILL_ADMIN` / `SUPER_ADMIN` 可隐藏或恢复评价,管理动作写入审计日志且不改变评分聚合。
|
||||
|
||||
## 7 异步事件汇总
|
||||
|
||||
| 事件 | 触发时机 | 消费方 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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 slug,SkillHub 会将其解析为 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 版本?
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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 文件、选择可见性后点击「发布」。
|
||||
|
|
|
|||
|
|
@ -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
|
|||
|
||||
> **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。
|
||||
|
||||
> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。
|
||||
|
||||
- **星标数量**:技能包的星标数会显示在搜索结果和详情页
|
||||
- **平均评分**:技能包的平均评分会影响搜索排序
|
||||
- **通知设置**:用户可以在设置中关闭某些类型的通知
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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. 分析器配置
|
||||
|
|
|
|||
87
scanner/skillhub_scanner_app.py
Normal file
87
scanner/skillhub_scanner_app.py
Normal 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
|
||||
131
scanner/tests/test_skillhub_scanner_app.py
Normal file
131
scanner/tests/test_skillhub_scanner_app.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -16,6 +16,7 @@ public record SecurityAuditResponse(
|
|||
Integer findingsCount,
|
||||
List<SecurityFinding> findings,
|
||||
Double scanDurationSeconds,
|
||||
String failureReason,
|
||||
Instant scannedAt,
|
||||
Instant createdAt
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record SkillReviewModerationRequest(
|
||||
@Size(max = 500) String reason
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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) <> '';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE security_audit
|
||||
ADD COLUMN failure_reason VARCHAR(1000);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=Отзывы доступны только для опубликованных навыков
|
||||
|
|
|
|||
|
|
@ -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=仅已发布的技能可以评价
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue