chore(redis): merge current main for cluster support

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-29 16:11:31 +08:00
commit dad3c15f92
189 changed files with 13839 additions and 704 deletions

View file

@ -18,6 +18,9 @@ SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
# Usually keep empty when web and api are served from the same domain.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Enable only when a trusted TLS-terminating proxy replaces X-Forwarded-Proto
# and the web container cannot be reached directly.
SKILLHUB_TRUST_FORWARDED_PROTO=false
# Keep database and redis local-only on the host unless you explicitly need remote access.
POSTGRES_BIND_ADDRESS=127.0.0.1

View file

@ -15,6 +15,9 @@ SKILLHUB_PUBLIC_BASE_URL=http://localhost
# Frontend usually keeps this empty and proxies to the backend through nginx.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Keep false for direct exposure. Enable only behind a trusted proxy that replaces
# X-Forwarded-Proto and blocks direct access to the web container.
SKILLHUB_TRUST_FORWARDED_PROTO=false
POSTGRES_BIND_ADDRESS=127.0.0.1
POSTGRES_PORT=5432

217
.github/workflows/pr-helm-chart.yml vendored Normal file
View file

@ -0,0 +1,217 @@
name: PR Helm Chart
on:
pull_request:
paths:
- charts/skillhub/**
- .github/workflows/pr-helm-chart.yml
- .github/workflows/publish-chart.yml
types:
- opened
- synchronize
- reopened
- ready_for_review
workflow_dispatch:
concurrency:
group: pr-helm-chart-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint Chart
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: charts/skillhub
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Build dependencies
run: helm dependency build .
- name: Lint chart
run: helm lint --strict . -f tests/test-values.yaml
- name: Validate configuration contracts
run: bash tests/configuration-contracts.sh
- name: Validate chart metadata
run: |
CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}')
APP_VERSION=$(helm show chart . | grep '^appVersion:' | awk '{print $2}')
echo "Chart version: $CHART_VERSION"
echo "App version: $APP_VERSION"
if [ -z "$CHART_VERSION" ]; then
echo "ERROR: Chart version is empty"
exit 1
fi
template:
name: Template Validation
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: charts/skillhub
strategy:
fail-fast: false
matrix:
scenario:
- name: bitnami-default
description: Bitnami 默认配置
args: ""
- name: external-db-redis
description: 外部 PostgreSQL + Redis
args: >-
--set postgresql.enabled=false
--set redis.enabled=false
--set externalDatabase.host=postgres.example.com
--set externalDatabase.password=secret
--set externalRedis.host=redis.example.com
--set externalRedis.password=secret
- name: postgresql-replication
description: PostgreSQL 主从 + Redis 主从
args: >-
--set postgresql.architecture=replication
--set redis.architecture=replication
- name: redis-sentinel
description: Redis 哨兵模式
args: >-
--set redis.architecture=replication
--set redis.sentinel.enabled=true
- name: ingress-tls-certmanager
description: Ingress + TLS + cert-manager
args: >-
--set ingress.enabled=true
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]'
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]'
--set ingress.certManager.enabled=true
- name: s3-storage
description: S3 存储
args: >-
--set s3.enabled=true
--set s3.bucket=test-bucket
--set s3.endpoint=https://s3.amazonaws.com
--set s3.region=us-east-1
- name: external-secret
description: 外部 Secret
args: >-
--set existingSecret=my-custom-secret
- name: scanner-disabled
description: 禁用 Scanner
args: >-
--set scanner.enabled=false
- name: hpa-pdb
description: HPA + PDB
args: >-
--set server.autoscaling.enabled=true
--set web.autoscaling.enabled=true
--set scanner.autoscaling.enabled=true
--set server.storage.accessMode=ReadWriteMany
--set server.podDisruptionBudget.enabled=true
--set web.podDisruptionBudget.enabled=true
--set scanner.podDisruptionBudget.enabled=true
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Build dependencies
run: helm dependency build .
- name: Render template - ${{ matrix.scenario.name }}
run: |
echo "## ${{ matrix.scenario.description }}"
helm template test-release . -f tests/test-values.yaml ${{ matrix.scenario.args }} > rendered.yaml
echo "✅ Template rendered successfully"
- name: Validate resources
run: |
RESOURCES=$(grep -c '^kind:' rendered.yaml || true)
echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}"
if [ "$RESOURCES" -eq 0 ]; then
echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}"
exit 1
fi
- name: Validate default dependency wiring
if: ${{ matrix.scenario.name == 'bitnami-default' }}
run: |
helm template test-release . -f tests/test-values.yaml --show-only templates/server-deployment.yaml > server.yaml
grep -Fq 'value: "test-release-postgresql"' server.yaml
grep -Fq 'value: "test-release-redis-master"' server.yaml
grep -Fq 'name: test-release-postgresql' server.yaml
grep -Fq 'name: test-release-redis' server.yaml
grep -Fq 'key: password' server.yaml
grep -Fq 'key: redis-password' server.yaml
if grep -Fq 'test-release-skillhub-postgresql' server.yaml; then
echo 'ERROR: Server references a non-existent PostgreSQL service'
exit 1
fi
if grep -Fq 'test-release-skillhub-redis' server.yaml; then
echo 'ERROR: Server references a non-existent Redis service'
exit 1
fi
- name: Schema validation (kubeconform)
uses: docker://ghcr.io/yannh/kubeconform@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e # v0.8.0
with:
entrypoint: '/kubeconform'
args: "-strict -summary -output text -schema-location default -schema-location https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json charts/skillhub/rendered.yaml"
install-upgrade:
name: Install and Upgrade Smoke (${{ matrix.scenario }})
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
scenario:
- default
- sentinel
- s3
- ingress-tls
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Create Kubernetes cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
with:
cluster_name: skillhub-helm-smoke
wait: 120s
- name: Run install and upgrade smoke
env:
HELM_SMOKE_SCENARIO: ${{ matrix.scenario }}
run: bash charts/skillhub/tests/install-upgrade-smoke.sh

View file

@ -8,9 +8,13 @@ on:
- '.env.release.draft'
- 'compose.release.yml'
- 'Makefile'
- 'web/Dockerfile'
- 'web/nginx.conf.template'
- '.github/workflows/pr-cli.yml'
- '.github/workflows/pr-e2e.yml'
- '.github/workflows/pr-helm-chart.yml'
- '.github/workflows/pr-tests.yml'
- '.github/workflows/publish-chart.yml'
- '.github/workflows/security.yml'
- '.github/workflows/pr-scripts.yml'
- '**/*.py'
@ -33,5 +37,6 @@ jobs:
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/nginx-forwarded-proto-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh

86
.github/workflows/publish-chart.yml vendored Normal file
View file

@ -0,0 +1,86 @@
name: Publish Helm Chart
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: Chart and application version (for example, 0.2.14)
required: true
type: string
concurrency:
group: publish-chart-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
release:
if: >-
github.event_name == 'workflow_dispatch' ||
startsWith(github.ref_name, 'v') ||
startsWith(github.ref_name, 'chart-v') ||
startsWith(github.ref_name, 'helm-v')
runs-on: ubuntu-latest
defaults:
run:
working-directory: charts/skillhub
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Verify dependencies
run: helm dependency build .
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Parse version from tag
id: ver
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VER="${{ inputs.version }}"
elif [[ "${{ github.ref_name }}" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
VER="${BASH_REMATCH[2]}"
elif [[ "${{ github.ref_name }}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
VER="${BASH_REMATCH[1]}"
else
echo "ERROR: Unsupported release tag: ${{ github.ref_name }}"
exit 1
fi
if [[ ! "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: Version must use MAJOR.MINOR.PATCH format: $VER"
exit 1
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Lint chart
run: helm lint . -f tests/test-values.yaml
- name: Package and push
run: |
helm package . \
--version "${{ steps.ver.outputs.version }}" \
--app-version "${{ steps.ver.outputs.version }}" \
--destination /tmp/helm-charts
helm push /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz \
oci://ghcr.io/${{ github.repository_owner }}/charts
- name: Upload chart artifact
uses: actions/upload-artifact@v4
with:
name: skillhub-${{ steps.ver.outputs.version }}.tgz
path: /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz
retention-days: 90

View file

@ -50,6 +50,8 @@ jobs:
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Check out repository

3
.gitignore vendored
View file

@ -84,5 +84,8 @@ docs/superpowers/
# Local workspace metadata
CLAUDE.md
# Helm chart dependencies
charts/skillhub/charts/*.tgz
# Local config file
.mcp.json

View file

@ -15,6 +15,9 @@
[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/)
[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev)
[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers)
</div>
<div align="center">
@ -35,6 +38,8 @@ it to a namespace, and let others find it through search or
install it via CLI. Built for on-premise deployment behind your
firewall, with the same polish you'd expect from a public registry.
> ⭐ If SkillHub fits your team, **star** the repo to help other teams find it, and **Watch → Custom → Releases** to get notified when a new version ships.
## Documentation
- 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides
@ -340,6 +345,20 @@ Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s):
- `services.yaml`
- `ingress.yaml`
For a configurable deployment with bundled PostgreSQL and Redis dependencies,
use the Helm chart under [`charts/skillhub/`](./charts/skillhub):
```bash
helm dependency build ./charts/skillhub
helm upgrade --install skillhub ./charts/skillhub \
--namespace skillhub \
--create-namespace \
-f values-production.yaml
```
See the [Helm chart guide](./charts/skillhub/README.md) for required secrets,
Ingress/TLS, external data services, storage migration, and upgrade constraints.
Apply them after creating your own secret:
```bash
@ -436,6 +455,19 @@ namespace `my-space` plus skill slug `my-skill`.
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) uses the standard `SKILL.md` format and recursively discovers skills under `$HERMES_HOME/skills/`. Use SkillHub CLI's explicit `--dir` option to install a complete SkillHub package into Hermes without a registry adapter, then verify it with `hermes skills list`.
📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)**
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) is a Go LLM programming assistant engine that exposes its capabilities over WebSocket. It loads skills from `SKILL.md` files with YAML frontmatter and parameter substitution, scanning each configured directory for `skill-name/SKILL.md` (default `~/.harnessclaw/workspace/skills/`, with earlier directories taking priority on name conflicts). Install a SkillHub package straight into that directory with the CLI's `--dir` option, no registry adapter required:
```bash
npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill
```
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization.

View file

@ -14,6 +14,9 @@
[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/)
[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev)
[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers)
</div>
---
@ -24,6 +27,8 @@
SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。
> ⭐ 如果 SkillHub 适合你的团队,欢迎 **Star** 本仓库帮助更多团队发现它;点 **Watch → Custom → Releases** 可在新版本发布时收到通知。
## 文档
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
@ -223,8 +228,10 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
# 应用 Kubernetes 清单
kubectl apply -f deploy/k8s/
# 或使用 Helm即将推出
helm install skillhub ./deploy/helm
# 或使用 Helm Chart
helm dependency build ./charts/skillhub
helm upgrade --install skillhub ./charts/skillhub -n skillhub --create-namespace \
-f values-production.yaml
```
### 环境变量
@ -314,7 +321,7 @@ SkillHub 采用清晰的分层架构:
### 基础设施
- **容器化**Docker & Docker Compose
- **监控**Prometheus + Grafana
- **部署**Kubernetes 清单
- **部署**Kubernetes 清单与 Helm Chart
- **CI/CD**GitHub Actions
## 路线图
@ -327,7 +334,7 @@ SkillHub 采用清晰的分层架构:
- [x] API 令牌管理
- [x] 账户合并
- [x] 国际化支持
- [ ] Helm Chart 部署
- [x] Helm Chart 部署
- [ ] 高级搜索过滤器
- [ ] 技能依赖管理
- [ ] Webhook 集成
@ -370,6 +377,19 @@ namespace `my-space` 和 skill slug `my-skill`。
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) 使用标准 `SKILL.md` 格式,并会递归发现 `$HERMES_HOME/skills/` 中的技能。通过 SkillHub CLI 的 `--dir` 参数即可把完整技能包安装到 Hermes无需新增 registry 适配器;安装后可使用 `hermes skills list` 验证。
📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)**
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) 是基于 Go 的 LLM 编程助手引擎,通过 WebSocket 协议对外提供能力。它从 `SKILL.md` 文件加载技能,支持 YAML frontmatter 与参数替换,并按配置顺序扫描各目录下的 `skill-name/SKILL.md`(默认 `~/.harnessclaw/workspace/skills/`,靠前的目录在重名时优先)。通过 SkillHub CLI 的 `--dir` 参数即可把技能包直接安装到该目录,无需新增 registry 适配器:
```bash
npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill
```
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。

View file

@ -0,0 +1,24 @@
# OS files
.DS_Store
Thumbs.db
# Editors / IDEs
.idea/
.vscode/
*.swp
*.swo
# Local tooling
.claude/
CLAUDE.md
# Git
.git/
.gitignore
.gitattributes
# CI
.github/
# Source-only contract tests
tests/

View file

@ -0,0 +1,9 @@
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 18.6.10
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 25.5.3
digest: sha256:20336709650cc49c81b8b4afdac0efeeea00cb88ff87820be9272ef5a7d545cc
generated: "2026-05-31T08:35:16.614393+08:00"

View file

@ -0,0 +1,27 @@
apiVersion: v2
name: skillhub
description: Self-hosted, open-source agent skill registry for enterprises.
type: application
version: 0.1.0
appVersion: 0.2.14
keywords:
- skillhub
- ai
- skills
home: https://github.com/iflytek/skillhub
icon: https://raw.githubusercontent.com/iflytek/skillhub/main/skillhub-logo.svg
sources:
- https://github.com/iflytek/skillhub
dependencies:
# PostgreSQL - Bitnami 官方 chart支持 HA、备份、监控
- name: postgresql
version: "18.6.10"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
# Redis - Bitnami 官方 chart支持集群模式、哨兵模式
- name: redis
version: "25.5.3"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled

475
charts/skillhub/README.md Normal file
View file

@ -0,0 +1,475 @@
# SkillHub Helm Chart
企业级 AI 技能中心私有化部署方案,基于 Kubernetes 和 Helm。
## 特性
- **微服务架构**ServerSpring Boot、WebNginx、Scanner 分离部署
- **高可用**:支持 HPA 自动扩缩容、PDB Pod 中断预算
- **数据层**:使用 Bitnami PostgreSQL/Redis支持主从复制、哨兵模式
- **安全**TLS 证书管理、Secret 密码保护Bitnami 数据组件默认提供 NetworkPolicy
- **可观测性**:内置 Prometheus metrics exporter
## 快速开始
### 前置要求
- Kubernetes 1.24+
- Helm 3.8+
- kubectl configured
### 安装
先创建受保护的 `values-production.yaml`。以下值必须替换为实际随机强密码:
```yaml
secrets:
allowAutoGenerated: false
bootstrapAdminPassword: "<固定管理员密码>"
downloadAnonCookieSecret: "<至少32字符的固定随机值>"
postgresql:
auth:
postgresPassword: "<固定PostgreSQL管理员密码>"
password: "<固定skillhub用户密码>"
redis:
auth:
password: "<固定Redis密码>"
```
```bash
helm dependency build ./charts/skillhub
kubectl create namespace skillhub
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set publicBaseUrl=https://skills.example.com
```
未显式设置 `deviceAuthVerificationUri`Chart 使用
`<publicBaseUrl>/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验,
无效的组件、Ingress、HPA 与存储组合会在安装前失败。
> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]`
> `ingress.tls[]`。旧的 `ingress.host``ingress.tls.enabled`
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。
合并或发布前,可在一个空的测试 Kubernetes 集群中运行可重复的安装/升级 smoke
```bash
for scenario in default sentinel s3 ingress-tls; do
HELM_SMOKE_SCENARIO="$scenario" \
bash charts/skillhub/tests/install-upgrade-smoke.sh
done
```
脚本验证 `install -> Ready -> HTTP health -> upgrade -> Ready`,并确认 Secret 数据、
PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默认依赖、Redis
Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。
默认清理自己创建的 namespace设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。
### 高可用模式
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set postgresql.architecture=replication \
--set postgresql.auth.replicationPassword=your-replication-password \
--set redis.architecture=replication
```
### 外部数据库模式
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set postgresql.enabled=false \
--set redis.enabled=false \
--set externalDatabase.host=postgres.example.com \
--set externalDatabase.port=5432 \
--set externalDatabase.database=skillhub \
--set externalDatabase.username=skillhub \
--set externalDatabase.password=your-db-password \
--set externalRedis.host=redis.example.com \
--set externalRedis.port=6379 \
--set externalRedis.password=your-redis-password
```
### 使用 existingSecret
通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。
内置 PostgreSQL/Redis 使用各自的 Bitnami Secret不需要复制到该 Secret。
| Key | 必填 | 说明 |
|-----|------|------|
| `spring-datasource-password` | 使用外部 PostgreSQL 时 | 数据库密码 |
| `redis-password` | 使用外部 Redis 时 | Redis 密码 |
| `redis-sentinel-password` | 使用外部 Sentinel 时 | Redis Sentinel 密码 |
| `bootstrap-admin-password` | 是 | 初始管理员密码 |
| `skillhub-download-anon-cookie-secret` | 是 | 至少 32 字符的匿名下载 Cookie 签名密钥 |
| `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID |
| `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret |
| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key |
| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 |
| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 |
| `skillhub-storage-s3-access-key` | 否 | S3 Access Key |
| `skillhub-storage-s3-secret-key` | 否 | S3 Secret Key |
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set existingSecret=my-custom-secret
```
### GitOps 稳定 Secret
Argo CD 等 GitOps 工具使用离线 `helm template`,无法通过 Helm `lookup` 读取集群
中已有的 Secret。Bitnami 子 Chart 和父 Chart 的空密码会在每次渲染时重新随机
生成。Chart 默认禁止自动生成并要求提供固定值:
```yaml
secrets:
allowAutoGenerated: false
bootstrapAdminPassword: "<固定管理员密码>"
downloadAnonCookieSecret: "<至少32字符的固定随机值>"
postgresql:
auth:
postgresPassword: "<固定PostgreSQL管理员密码>"
password: "<固定skillhub用户密码>"
# replication 架构还必须配置 replicationPassword
redis:
auth:
password: "<固定Redis密码>"
```
也可以为三个组件分别配置 `existingSecret``allowAutoGenerated=false` 不会生成
可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在
受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。
内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变的
多架构 manifest digest避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的
安装和回滚,同时保留 amd64/arm64 支持。覆盖私有镜像仓库或 tag 时,必须同时把
对应的 `image.digest` 设为空,或改成私有仓库中该镜像的真实 digestdigest
非空时会优先于 tag。
## 配置参考
### 副本数配置
| 参数 | 描述 | 默认值 |
|------|------|--------|
| `server.replicaCount` | Server 副本数 | `1` |
| `web.replicaCount` | Web 副本数 | `1` |
| `scanner.replicaCount` | Scanner 副本数 | `1` |
```bash
# 差异化副本配置
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set server.replicaCount=3 \
--set server.storage.accessMode=ReadWriteMany \
--set web.replicaCount=2 \
--set scanner.replicaCount=1
```
本地存储运行多个 Server 副本时,必须显式设置 `ReadWriteMany`,并使用支持 RWX
的 StorageClass。无法提供 RWX 时应改用 S3。
### 服务配置
| 参数 | 描述 | 默认值 |
|------|------|--------|
| `server.service.type` | Server Service 类型 | `ClusterIP` |
| `server.service.port` | Server 端口 | `8080` |
| `web.service.type` | Web Service 类型 | `ClusterIP` |
| `web.service.port` | Web 端口 | `80` |
| `scanner.service.port` | Scanner 端口 | `8000` |
### 私有镜像仓库
使用私有仓库时,需要分别覆盖 SkillHub 镜像、依赖等待镜像和 Bitnami 子 Chart
镜像。以下示例中的数据库镜像标签均为明确版本,不使用 `latest`
```yaml
global:
imagePullSecrets:
- private-registry
security:
allowInsecureImages: true
images:
registry: registry.example.com/library
tag: v0.2.14
pullPolicy: IfNotPresent
server:
dependencyWait:
image:
registry: registry.example.com
repository: library/busybox
tag: "1.37"
pullPolicy: IfNotPresent
imagePullSecrets:
- name: private-registry
web:
imagePullSecrets:
- name: private-registry
scanner:
imagePullSecrets:
- name: private-registry
postgresql:
image:
registry: registry.example.com
repository: library/postgresql
tag: 18.4.0
digest: ""
metrics:
image:
registry: registry.example.com
repository: library/postgres-exporter
tag: 0.20.1
digest: ""
redis:
image:
registry: registry.example.com
repository: library/redis
tag: 8.8.0
digest: ""
sentinel:
image:
registry: registry.example.com
repository: library/redis-sentinel
tag: 8.8.0
digest: ""
metrics:
image:
registry: registry.example.com
repository: library/redis-exporter
tag: 1.86.0
digest: ""
```
`global.security.allowInsecureImages` 是 Bitnami 对自定义镜像仓库和镜像名称的校验
开关,并不表示使用不安全的 HTTP 仓库。先在目标 namespace 创建拉取凭据:
```bash
kubectl create secret docker-registry private-registry \
-n skillhub \
--docker-server=registry.example.com \
--docker-username='<用户名>' \
--docker-password='<密码>'
```
### 数据库配置
| 参数 | 描述 | 默认值 |
|------|------|--------|
| `postgresql.enabled` | 启用内置 PostgreSQL | `true` |
| `postgresql.architecture` | 架构模式 | `standalone` |
| `redis.enabled` | 启用内置 Redis | `true` |
| `redis.architecture` | 架构模式 | `standalone` |
#### 数据库架构支持边界
以下内置数据库目标架构已完成独立 namespace 的全新安装和运行时验证:
| 数据组件 | 已验证架构 | 运行时验证 |
|----------|------------|------------|
| PostgreSQL | standalone | Server 连接、Flyway 和应用启动 |
| PostgreSQL | replication | 1 Primary + 2 Read Replicas两个副本均处于 recovery流复制状态为 `streaming` |
| Redis | standalone | Server 读写和应用启动 |
| Redis | replication | 1 Master + 2 Replicas角色和数据复制正常 |
| Redis | replication + Sentinel | 3 个 Sentinel 节点 master 视图一致Server 可通过 Sentinel 读写 |
上述支持表示 Chart 能够全新部署目标架构,并为 SkillHub 配置正确的写节点或
Sentinel 地址。Chart **不负责数据库架构切换时的数据迁移**,也不承诺仅修改
`architecture``sentinel.enabled` 就能保留已有数据。已有数据的 PostgreSQL
standalone → replication、Redis standalone/replication → Sentinel 等切换,必须由
运维人员在 Chart 之外完成备份、恢复、PVC 复用或其他迁移方案。
### Redis Sentinel
内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和
Sentinel。节点地址由副本数自动生成不需要手动配置。由于 Bitnami Sentinel
上报的 Pod 地址可能与客户端连接的 Headless Service FQDN 不同Chart 仅在该
内置模式下关闭 Redisson 的 Sentinel 地址一致性检查:
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set redis.architecture=replication \
--set redis.sentinel.enabled=true
```
外部 Sentinel 必须提供至少一个 `host:port` 节点。Redis 数据密码和 Sentinel
密码可以不同;使用 `existingSecret` 时分别对应 `redis-password`
`redis-sentinel-password`。外部 Sentinel 默认保留 Redisson 地址一致性检查;
只有已确认服务发现会改写节点地址时,才通过 `server.extraEnv` 显式设置
`SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=false`
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set redis.enabled=false \
--set externalRedis.password=redis-password \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.sentinel.password=sentinel-password \
--set-json 'externalRedis.sentinel.nodes=["sentinel-0.example.com:26379","sentinel-1.example.com:26379"]'
```
确需关闭检查时,在 values 文件中显式记录该兼容例外:
```yaml
server:
extraEnv:
- name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
value: "false"
```
### 存储配置
| 参数 | 描述 | 默认值 |
|------|------|--------|
| `server.storage.accessMode` | 留空时单副本使用 ReadWriteOnce多副本必须显式使用 ReadWriteMany | `""` |
| `server.storage.size` | PVC 大小 | `10Gi` |
| `server.storage.storageClassName` | StorageClass | `""` |
| `server.podSecurityContext.fsGroup` | Server 本地存储的可写组 ID应与镜像内 app 用户组一致 | `101` |
| `server.podSecurityContext.fsGroupChangePolicy` | kubelet 调整 PVC 组权限的策略 | `OnRootMismatch` |
本地 PVC 会覆盖镜像内预先设置的目录所有者。Chart 默认通过 Pod `fsGroup=101`
使 Server 的非 root `app` 用户可以创建和更新技能文件。使用自定义 Server 镜像且其
运行组 ID 不同时,必须同步覆盖 `server.podSecurityContext.fsGroup`
使用本地 `ReadWriteOnce` PVC 时Server Deployment 自动采用 `Recreate`,避免
滚动升级期间新旧 Pod 同时挂载非共享卷而触发 Multi-Attach。单副本升级会有短暂
停机;使用支持 RWX 的 `ReadWriteMany` 存储或启用 S3 时Chart 保留
`RollingUpdate`
```bash
# 默认使用本地 PVC
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml
```
### S3 对象存储
`s3.enabled=true` 时,不创建 PVC应用使用 S3 作为存储后端。
| 参数 | 描述 | 默认值 |
|------|------|--------|
| `s3.enabled` | 启用 S3 | `false` |
| `s3.bucket` | Bucket 名称 | `skillhub-storage` |
| `s3.endpoint` | S3 端点,非空时必须是绝对 HTTP(S) URL | `""` |
| `s3.publicEndpoint` | S3 公网访问端点,非空时必须是绝对 HTTP(S) URL | `""` |
| `s3.region` | 区域 | `us-east-1` |
| `s3.forcePathStyle` | 强制 path-style 访问 | `true` |
| `s3.disableChunkedEncoding` | 禁用 aws-chunked 编码 | `false` |
| `s3.autoCreateBucket` | 自动创建 Bucket | `false` |
| `s3.accessKey` | Access Key | `""` |
| `s3.secretKey` | Secret Key | `""` |
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set s3.enabled=true \
--set s3.bucket=your-bucket \
--set s3.endpoint=https://s3.amazonaws.com \
--set s3.region=us-east-1 \
--set s3.accessKey=your-access-key \
--set s3.secretKey=your-secret-key
```
### Ingress + TLS
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set ingress.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]' \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
--set publicBaseUrl=https://skills.example.com \
--set ingress.certManager.enabled=true
```
配置非空 `ingress.tls` 或启用 `ingress.certManager`Chart 会自动将 Session Cookie
标记为 Secure。Ingress 要求 Server 和 Web Service 均保持启用。
`ingress.className` 和旧式 `kubernetes.io/ingress.class` annotation 均受支持,
可以任选其一,也可以同时输出。仅使用旧式 annotation 时将 `className` 留空:
```yaml
ingress:
enabled: true
className: ""
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":6443}]'
```
`hosts` 是至少包含一个条目的对象数组。Chart 自动将 `/api``/oauth2`
`/login/oauth2``/.well-known` 直接转发给 Server确保 TLS 终止后的 OAuth
回调协议保持正确;`hosts[].paths` 中的其他路径转发给 Web因此上述四个前缀
均为保留路径。`tls` 同样是数组可为不同证书分别配置域名TLS 域名会写入
cert-manager Certificate SAN
```yaml
ingress:
hosts:
- host: skills.example.com
paths:
- path: /
pathType: Prefix
- host: skills.internal.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- skills.example.com
- skills.internal.example.com
secretName: skills-tls
```
### 自动扩缩容
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set server.autoscaling.enabled=true \
--set server.autoscaling.minReplicas=2 \
--set server.autoscaling.maxReplicas=10 \
--set server.storage.accessMode=ReadWriteMany
```
每个 HPA 至少需要一个非零 CPU 或内存利用率目标。本地存储的 Server HPA 同样
要求 RWX也可以启用 S3 来避免共享 PVC。
## 发布
`.github/workflows/publish-chart.yml` 在 GitHub Release 发布后或手动
`workflow_dispatch` 时运行。Release tag 必须使用 `vX.Y.Z``chart-vX.Y.Z`
`helm-vX.Y.Z`;手动运行时显式输入 `X.Y.Z`。工作流按该版本打包 Chart并推送到
`oci://ghcr.io/iflytek/charts`,同时保留构建 artifact。
## 卸载
```bash
helm -n skillhub uninstall skillhub
```
Server 数据 PVC 带有 `helm.sh/resource-policy: keep`,卸载 release 后仍会保留,
需要确认数据不再使用后手动删除。
## 依赖
| 依赖 | 版本 |
|------|------|
| postgresql | 18.6.10 |
| redis | 25.5.3 |

View file

@ -0,0 +1,255 @@
{{- /*
SkillHub Helm Chart 模板辅助函数
*/}}
{{- /* */}}
{{- define "skillhub.name" -}}
{{- default "skillhub" .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- /* */}}
{{- define "skillhub.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default "skillhub" .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- /* Chart */}}
{{- define "skillhub.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- /* */}}
{{- define "skillhub.labels" -}}
helm.sh/chart: {{ include "skillhub.chart" . }}
{{ include "skillhub.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: skillhub
{{- end }}
{{- /* */}}
{{- define "skillhub.selectorLabels" -}}
app.kubernetes.io/name: {{ include "skillhub.name" . }}
{{- end }}
{{- /* */}}
{{- define "skillhub.server.labels" -}}
{{ include "skillhub.labels" . }}
app.kubernetes.io/component: server
{{- end }}
{{- define "skillhub.server.selectorLabels" -}}
{{ include "skillhub.selectorLabels" . }}
app.kubernetes.io/component: server
{{- end }}
{{- define "skillhub.web.labels" -}}
{{ include "skillhub.labels" . }}
app.kubernetes.io/component: web
{{- end }}
{{- define "skillhub.web.selectorLabels" -}}
{{ include "skillhub.selectorLabels" . }}
app.kubernetes.io/component: web
{{- end }}
{{- define "skillhub.scanner.labels" -}}
{{ include "skillhub.labels" . }}
app.kubernetes.io/component: scanner
{{- end }}
{{- define "skillhub.scanner.selectorLabels" -}}
{{ include "skillhub.selectorLabels" . }}
app.kubernetes.io/component: scanner
{{- end }}
{{- /* Bitnami PostgreSQL subchart */}}
{{- define "skillhub.postgresql.fullname" -}}
{{- if .Values.postgresql.fullnameOverride -}}
{{- .Values.postgresql.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default "postgresql" .Values.postgresql.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* Bitnami Redis subchart */}}
{{- define "skillhub.redis.fullname" -}}
{{- if .Values.redis.fullnameOverride -}}
{{- .Values.redis.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default "redis" .Values.redis.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Host */}}
{{- define "skillhub.postgresql.host" -}}
{{- if .Values.postgresql.enabled -}}
{{- $prefix := include "skillhub.postgresql.fullname" . -}}
{{- if eq .Values.postgresql.architecture "replication" -}}
{{- printf "%s-primary" $prefix -}}
{{- else -}}
{{- $prefix -}}
{{- end -}}
{{- else -}}
{{- .Values.externalDatabase.host -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Port */}}
{{- define "skillhub.postgresql.port" -}}
{{- if .Values.postgresql.enabled -}}
{{- print "5432" -}}
{{- else -}}
{{- .Values.externalDatabase.port | default 5432 | int -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Database */}}
{{- define "skillhub.postgresql.database" -}}
{{- if .Values.postgresql.enabled -}}
{{- .Values.postgresql.auth.database -}}
{{- else -}}
{{- .Values.externalDatabase.database -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Username */}}
{{- define "skillhub.postgresql.username" -}}
{{- if .Values.postgresql.enabled -}}
{{- .Values.postgresql.auth.username -}}
{{- else -}}
{{- .Values.externalDatabase.username -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Secret Name */}}
{{- define "skillhub.postgresql.secretName" -}}
{{- if .Values.postgresql.enabled -}}
{{- .Values.postgresql.auth.existingSecret | default (include "skillhub.postgresql.fullname" .) -}}
{{- else -}}
{{- include "skillhub.secretName" . -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL Secret keypostgres 使使 */}}
{{- define "skillhub.postgresql.passwordKey" -}}
{{- if eq .Values.postgresql.auth.username "postgres" -}}
{{- .Values.postgresql.auth.secretKeys.adminPasswordKey | default "postgres-password" -}}
{{- else -}}
{{- .Values.postgresql.auth.secretKeys.userPasswordKey | default "password" -}}
{{- end -}}
{{- end }}
{{- /* PostgreSQL JDBC URL */}}
{{- define "skillhub.jdbcUrl" -}}
{{- if .Values.postgresql.enabled -}}
{{- printf "jdbc:postgresql://%s:5432/%s" (include "skillhub.postgresql.host" .) .Values.postgresql.auth.database -}}
{{- else -}}
{{- if .Values.externalDatabase.jdbcUrl -}}
{{- .Values.externalDatabase.jdbcUrl -}}
{{- else -}}
{{- printf "jdbc:postgresql://%s:%d/%s" .Values.externalDatabase.host (.Values.externalDatabase.port | default 5432 | int) .Values.externalDatabase.database -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* Redis Sentinel Redisson pod FQDN: {pod}.{headless-svc}.{ns}.svc.cluster.local */}}
{{- define "skillhub.redis.sentinel.nodes" -}}
{{- $fullname := include "skillhub.redis.fullname" . -}}
{{- $prefix := printf "%s-node" $fullname -}}
{{- $headless := printf "%s-headless" $fullname -}}
{{- /* Headless Service DNS resolves directly to pod IPs, so use the container port. */ -}}
{{- $port := .Values.redis.sentinel.containerPorts.sentinel | default 26379 -}}
{{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}}
{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%v" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}}
{{- end }}
{{- /* Redis Host */}}
{{- define "skillhub.redis.host" -}}
{{- if .Values.redis.enabled -}}
{{- if .Values.redis.sentinel.enabled -}}
{{- include "skillhub.redis.fullname" . -}}
{{- else -}}
{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}}
{{- end -}}
{{- else -}}
{{- .Values.externalRedis.host -}}
{{- end -}}
{{- end }}
{{- /* Redis Port */}}
{{- define "skillhub.redis.port" -}}
{{- if .Values.redis.enabled -}}
{{- if .Values.redis.sentinel.enabled -}}
{{- .Values.redis.sentinel.service.ports.sentinel | default 26379 -}}
{{- else -}}
{{- print "6379" -}}
{{- end -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- last (splitList ":" $node) -}}
{{- else -}}
{{- .Values.externalRedis.port | default 6379 | int -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* Redis Password Secret Name */}}
{{- define "skillhub.redis.secretName" -}}
{{- if .Values.redis.enabled -}}
{{- .Values.redis.auth.existingSecret | default (include "skillhub.redis.fullname" .) -}}
{{- else -}}
{{- include "skillhub.secretName" . -}}
{{- end -}}
{{- end }}
{{- /* Redis Secret key */}}
{{- define "skillhub.redis.passwordKey" -}}
{{- .Values.redis.auth.existingSecretPasswordKey | default "redis-password" -}}
{{- end }}
{{- /* Secret */}}
{{- define "skillhub.secretName" -}}
{{- .Values.existingSecret | default (printf "%s-secret" (include "skillhub.fullname" .)) }}
{{- end }}
{{- /* PostgreSQL Service server initContainer */}}
{{- define "skillhub.postgresql.serviceName" -}}
{{- if .Values.postgresql.enabled -}}
{{- include "skillhub.postgresql.host" . -}}
{{- else -}}
{{- .Values.externalDatabase.host -}}
{{- end -}}
{{- end }}
{{- /* Redis Service server initContainer */}}
{{- define "skillhub.redis.serviceName" -}}
{{- if .Values.redis.enabled -}}
{{- include "skillhub.redis.host" . -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else -}}
{{- .Values.externalRedis.host -}}
{{- end -}}
{{- end -}}
{{- end }}

View file

@ -0,0 +1,24 @@
{{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled }}
{{- range $index, $tls := .Values.ingress.tls }}
{{- if $index }}
---
{{- end }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $tls.secretName }}-cert
labels:
{{- include "skillhub.labels" $ | nindent 4 }}
spec:
secretName: {{ $tls.secretName }}
duration: 2160h
renewBefore: 360h
dnsNames:
{{- range $tls.hosts }}
- {{ . | quote }}
{{- end }}
issuerRef:
name: {{ $.Values.ingress.certManager.issuerName | quote }}
kind: {{ $.Values.ingress.certManager.issuerKind | quote }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,57 @@
{{- /*
SkillHub 应用 ConfigMap
*/}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "skillhub.fullname" . }}-config
labels:
{{- include "skillhub.labels" . | nindent 4 }}
data:
# Redis 配置
redis-host: {{ include "skillhub.redis.host" . | quote }}
redis-port: {{ include "skillhub.redis.port" . | quote }}
# 存储路径
storage-base-path: "/var/lib/skillhub/storage"
# 存储提供者: local | s3
skillhub-storage-provider: {{ if .Values.s3.enabled }}"s3"{{ else }}"local"{{ end }}
{{- if .Values.s3.enabled }}
# S3 配置
s3-bucket: {{ .Values.s3.bucket | quote }}
s3-endpoint: {{ .Values.s3.endpoint | quote }}
s3-public-endpoint: {{ .Values.s3.publicEndpoint | quote }}
s3-region: {{ .Values.s3.region | quote }}
s3-force-path-style: {{ .Values.s3.forcePathStyle | quote }}
s3-disable-chunked-encoding: {{ .Values.s3.disableChunkedEncoding | quote }}
s3-auto-create-bucket: {{ .Values.s3.autoCreateBucket | quote }}
s3-presign-expiry: {{ .Values.s3.presignExpiry | quote }}
{{- end }}
# 技能扫描器
skill-scanner-enabled: {{ .Values.scanner.enabled | quote }}
skill-scanner-url: {{ printf "http://%s-scanner:%v" (include "skillhub.fullname" .) .Values.scanner.service.port | quote }}
skill-scanner-mode: "upload"
# Bootstrap 管理员
bootstrap-admin-enabled: {{ .Values.bootstrapAdmin.enabled | quote }}
bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId | quote }}
bootstrap-admin-username: {{ .Values.bootstrapAdmin.username | quote }}
bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName | quote }}
bootstrap-admin-email: {{ .Values.bootstrapAdmin.email | quote }}
# Session
session-cookie-secure: {{ or .Values.session.cookieSecure (not (empty .Values.ingress.tls)) .Values.ingress.certManager.enabled | quote }}
# Public URL and authentication
public-base-url: {{ .Values.publicBaseUrl | quote }}
{{- $deviceAuthVerificationUri := .Values.deviceAuthVerificationUri }}
{{- if and (not $deviceAuthVerificationUri) .Values.publicBaseUrl }}
{{- $deviceAuthVerificationUri = printf "%s/cli/auth" (trimSuffix "/" .Values.publicBaseUrl) }}
{{- end }}
device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }}
auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }}
auth-direct-provider: {{ .Values.auth.direct.provider | quote }}
builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }}

View file

@ -0,0 +1,40 @@
{{- range $name := list "server" "web" "scanner" }}
{{- $component := index $.Values $name }}
{{- $enabled := true }}
{{- if hasKey $component "enabled" }}
{{- $enabled = $component.enabled }}
{{- end }}
{{- if and $enabled $component.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "skillhub.fullname" $ }}-{{ $name }}
labels:
{{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "skillhub.fullname" $ }}-{{ $name }}
minReplicas: {{ $component.autoscaling.minReplicas }}
maxReplicas: {{ $component.autoscaling.maxReplicas }}
metrics:
{{- if $component.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ $component.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if $component.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ $component.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,64 @@
{{- if .Values.ingress.enabled }}
{{- $hosts := .Values.ingress.hosts }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "skillhub.fullname" . }}
labels:
{{- include "skillhub.labels" . | nindent 4 }}
{{- if .Values.ingress.annotations }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className | quote }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range $host := $hosts }}
- host: {{ $host.host | quote }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: {{ include "skillhub.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
- path: /oauth2
pathType: Prefix
backend:
service:
name: {{ include "skillhub.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
- path: /login/oauth2
pathType: Prefix
backend:
service:
name: {{ include "skillhub.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
- path: /.well-known
pathType: Prefix
backend:
service:
name: {{ include "skillhub.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
{{- range $path := $host.paths }}
- path: {{ $path.path | quote }}
pathType: {{ $path.pathType }}
backend:
service:
name: {{ include "skillhub.fullname" $ }}-web
port:
number: {{ $.Values.web.service.port }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,21 @@
{{- range $name := list "server" "web" "scanner" }}
{{- $component := index $.Values $name }}
{{- $enabled := true }}
{{- if hasKey $component "enabled" }}
{{- $enabled = $component.enabled }}
{{- end }}
{{- if and $enabled $component.podDisruptionBudget.enabled }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "skillhub.fullname" $ }}-{{ $name }}
labels:
{{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }}
spec:
selector:
matchLabels:
{{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 6 }}
minAvailable: {{ $component.podDisruptionBudget.minAvailable }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,23 @@
{{- if and .Values.server.enabled (not .Values.s3.enabled) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "skillhub.fullname" . }}-server-data
labels:
{{- include "skillhub.labels" . | nindent 4 }}
annotations:
helm.sh/resource-policy: keep
spec:
{{- $accessMode := .Values.server.storage.accessMode }}
{{- if not $accessMode }}
{{- $accessMode = "ReadWriteOnce" }}
{{- end }}
accessModes:
- {{ $accessMode }}
{{- if .Values.server.storage.storageClassName }}
storageClassName: {{ .Values.server.storage.storageClassName | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.server.storage.size }}
{{- end }}

View file

@ -0,0 +1,77 @@
{{- if .Values.scanner.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "skillhub.fullname" . }}-scanner
labels:
{{- include "skillhub.scanner.labels" . | nindent 4 }}
spec:
{{- if not .Values.scanner.autoscaling.enabled }}
replicas: {{ .Values.scanner.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "skillhub.scanner.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "skillhub.scanner.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ toYaml (dict "scanner" .Values.scanner "secrets" .Values.secrets "existingSecret" .Values.existingSecret) | sha256sum }}
{{- range $key, $val := .Values.scanner.podAnnotations }}
{{ $key }}: {{ $val }}
{{- end }}
spec:
{{- $secrets := .Values.scanner.imagePullSecrets }}
{{- if $secrets }}
imagePullSecrets:
{{- toYaml $secrets | nindent 8 }}
{{- end }}
containers:
- name: scanner
image: {{ .Values.scanner.image.registry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.scanner.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }}
imagePullPolicy: {{ .Values.images.pullPolicy }}
ports:
- containerPort: {{ .Values.scanner.service.port }}
name: http
env:
- name: SKILL_SCANNER_LLM_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skill-scanner-llm-api-key
optional: true
- name: SKILL_SCANNER_LLM_BASE_URL
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skill-scanner-llm-base-url
optional: true
- name: SKILL_SCANNER_LLM_MODEL
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skill-scanner-llm-model
optional: true
{{- with .Values.scanner.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.scanner.resources | nindent 12 }}
readinessProbe:
{{- toYaml .Values.scanner.probes.readiness | nindent 12 }}
livenessProbe:
{{- toYaml .Values.scanner.probes.liveness | nindent 12 }}
{{- with .Values.scanner.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.scanner.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.scanner.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,80 @@
{{- /*
SkillHub 应用 Secret
- 内置 PostgreSQL/Redis密码由 Bitnami 管理,从对应 Secret 读取
- 外部 PostgreSQL/Redis密码从 values 或 existingSecret 读取
*/}}
{{- if not .Values.existingSecret }}
{{- $secretName := include "skillhub.secretName" . }}
{{- $appSecret := (lookup "v1" "Secret" $.Release.Namespace $secretName) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
{{- include "skillhub.labels" . | nindent 4 }}
type: Opaque
stringData:
{{- if not .Values.postgresql.enabled }}
# 外部数据库密码;内置 PostgreSQL 直接引用 Bitnami Secret
spring-datasource-password: {{ .Values.externalDatabase.password | quote }}
{{- end }}
{{- if not .Values.redis.enabled }}
# 外部 Redis 密码;内置 Redis 直接引用 Bitnami Secret
redis-password: {{ .Values.externalRedis.password | default "" | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }}
# 外部 Sentinel 可使用独立密码
redis-sentinel-password: {{ .Values.externalRedis.sentinel.password | default .Values.externalRedis.password | default "" | quote }}
{{- end }}
# Bootstrap 管理员密码
# 优先级: secrets.bootstrapAdminPassword → bootstrapAdmin.password → 集群已有 Secret → 随机生成
{{- $baPwd := .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default "" }}
{{- if not $baPwd }}
{{- if $appSecret }}
{{- $baPwd = index $appSecret.data "bootstrap-admin-password" | default "" | b64dec }}
{{- end }}
{{- if not $baPwd }}
{{- $baPwd = randAlphaNum 16 }}
{{- end }}
{{- end }}
bootstrap-admin-password: {{ $baPwd | quote }}
# 匿名下载限流 Cookie 签名密钥
{{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }}
{{- if and (not $downloadSecret) $appSecret }}
{{- $downloadSecret = index $appSecret.data "skillhub-download-anon-cookie-secret" | default "" | b64dec }}
{{- end }}
{{- if not $downloadSecret }}
{{- $downloadSecret = randAlphaNum 48 }}
{{- end }}
skillhub-download-anon-cookie-secret: {{ $downloadSecret | quote }}
# OAuth2 GitHub (optional)
{{- if .Values.secrets.oauth2GithubClientId }}
oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId | quote }}
{{- end }}
{{- if .Values.secrets.oauth2GithubClientSecret }}
oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }}
{{- end }}
# Scanner LLM 配置 (optional)
{{- if .Values.secrets.scannerLlmApiKey }}
skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }}
{{- end }}
{{- if .Values.secrets.scannerLlmBaseUrl }}
skill-scanner-llm-base-url: {{ .Values.secrets.scannerLlmBaseUrl | quote }}
{{- end }}
{{- if .Values.secrets.scannerLlmModel }}
skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel | quote }}
{{- end }}
# S3 配置 (optional)
{{- if .Values.s3.accessKey }}
skillhub-storage-s3-access-key: {{ .Values.s3.accessKey | quote }}
{{- end }}
{{- if .Values.s3.secretKey }}
skillhub-storage-s3-secret-key: {{ .Values.s3.secretKey | quote }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,370 @@
{{- if .Values.server.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "skillhub.fullname" . }}-server
labels:
{{- include "skillhub.server.labels" . | nindent 4 }}
spec:
{{- if not .Values.server.autoscaling.enabled }}
replicas: {{ .Values.server.replicaCount }}
{{- end }}
strategy:
{{- if and (not .Values.s3.enabled) (ne .Values.server.storage.accessMode "ReadWriteMany") }}
type: Recreate
{{- else }}
type: RollingUpdate
{{- end }}
selector:
matchLabels:
{{- include "skillhub.server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "skillhub.server.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- range $key, $val := .Values.server.podAnnotations }}
{{ $key }}: {{ $val }}
{{- end }}
spec:
{{- with .Values.server.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $secrets := .Values.server.imagePullSecrets }}
{{- if $secrets }}
imagePullSecrets:
{{- toYaml $secrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-dependencies
image: {{ printf "%s/%s:%s" .Values.server.dependencyWait.image.registry .Values.server.dependencyWait.image.repository .Values.server.dependencyWait.image.tag | quote }}
imagePullPolicy: {{ .Values.server.dependencyWait.image.pullPolicy }}
env:
- name: DB_HOST
value: {{ include "skillhub.postgresql.serviceName" . | quote }}
- name: DB_PORT
value: {{ include "skillhub.postgresql.port" . | quote }}
- name: REDIS_HOST
value: {{ include "skillhub.redis.serviceName" . | quote }}
- name: REDIS_PORT
value: {{ include "skillhub.redis.port" . | quote }}
command:
- sh
- -c
- |
echo "Waiting for PostgreSQL at ${DB_HOST}:${DB_PORT}..."
until nc -z -w 2 "${DB_HOST}" "${DB_PORT}"; do sleep 2; done
echo "PostgreSQL is ready!"
echo "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
until nc -z -w 2 "${REDIS_HOST}" "${REDIS_PORT}"; do sleep 2; done
echo "Redis is ready!"
containers:
- name: server
image: {{ .Values.server.image.registry | default .Values.images.registry }}/skillhub-server:{{ .Values.server.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }}
imagePullPolicy: {{ .Values.images.pullPolicy }}
ports:
- containerPort: {{ .Values.server.service.port }}
name: http
env:
- name: SPRING_PROFILES_ACTIVE
{{- $profiles := .Values.springProfilesActive }}
{{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }}
{{- $profiles = printf "%s,redis-sentinel" $profiles }}
{{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }}
{{- $profiles = printf "%s,redis-sentinel" $profiles }}
{{- end }}
value: {{ $profiles | quote }}
# Database
- name: SPRING_DATASOURCE_URL
value: {{ include "skillhub.jdbcUrl" . | quote }}
- name: SPRING_DATASOURCE_USERNAME
value: {{ include "skillhub.postgresql.username" . | quote }}
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.postgresql.enabled }}
name: {{ include "skillhub.postgresql.secretName" . }}
key: {{ include "skillhub.postgresql.passwordKey" . }}
{{- else }}
name: {{ include "skillhub.secretName" . }}
key: spring-datasource-password
{{- end }}
# Redis
{{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }}
- name: SPRING_DATA_REDIS_SENTINEL_MASTER
value: {{ .Values.redis.sentinel.masterSet | default "mymaster" | quote }}
- name: SPRING_DATA_REDIS_SENTINEL_NODES
value: {{ include "skillhub.redis.sentinel.nodes" . | quote }}
# Bitnami Sentinel pods advertise pod-local addresses that can differ from
# the headless-service FQDNs used by clients inside Kubernetes.
- name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
value: "false"
{{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }}
- name: SPRING_DATA_REDIS_SENTINEL_MASTER
value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }}
- name: SPRING_DATA_REDIS_SENTINEL_NODES
value: {{ join "," .Values.externalRedis.sentinel.nodes | quote }}
{{- else }}
- name: SPRING_DATA_REDIS_HOST
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: redis-host
- name: SPRING_DATA_REDIS_PORT
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: redis-port
{{- end }}
{{- if or (and .Values.redis.enabled .Values.redis.sentinel.enabled) (and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled) }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.redis.enabled }}
name: {{ include "skillhub.redis.secretName" . }}
key: {{ include "skillhub.redis.passwordKey" . }}
{{- else }}
name: {{ include "skillhub.secretName" . }}
key: redis-password
{{- end }}
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.redis.enabled }}
name: {{ include "skillhub.redis.secretName" . }}
key: {{ include "skillhub.redis.passwordKey" . }}
{{- else }}
name: {{ include "skillhub.secretName" . }}
key: redis-sentinel-password
{{- end }}
optional: true
{{- else if or .Values.redis.enabled .Values.externalRedis.password }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.redis.enabled }}
name: {{ include "skillhub.redis.secretName" . }}
{{- else }}
name: {{ include "skillhub.secretName" . }}
{{- end }}
key: {{ if .Values.redis.enabled }}{{ include "skillhub.redis.passwordKey" . }}{{ else }}redis-password{{ end }}
optional: true
{{- end }}
# Storage
- name: STORAGE_BASE_PATH
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: storage-base-path
- name: SKILLHUB_STORAGE_PROVIDER
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: skillhub-storage-provider
{{- if .Values.s3.enabled }}
- name: SKILLHUB_STORAGE_S3_BUCKET
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-bucket
- name: SKILLHUB_STORAGE_S3_ENDPOINT
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-endpoint
- name: SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-public-endpoint
- name: SKILLHUB_STORAGE_S3_REGION
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-region
- name: SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-force-path-style
- name: SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-disable-chunked-encoding
- name: SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-auto-create-bucket
- name: SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: s3-presign-expiry
- name: SKILLHUB_STORAGE_S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skillhub-storage-s3-access-key
optional: true
- name: SKILLHUB_STORAGE_S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skillhub-storage-s3-secret-key
optional: true
{{- end }}
# Scanner
- name: SKILLHUB_SECURITY_SCANNER_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: skill-scanner-enabled
- name: SKILLHUB_SECURITY_SCANNER_URL
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: skill-scanner-url
- name: SKILLHUB_SECURITY_SCANNER_MODE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: skill-scanner-mode
# Session
- name: SESSION_COOKIE_SECURE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: session-cookie-secure
# Public URL and authentication
- name: SKILLHUB_PUBLIC_BASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: public-base-url
- name: DEVICE_AUTH_VERIFICATION_URI
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: device-auth-verification-uri
- name: SKILLHUB_AUTH_DIRECT_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-direct-enabled
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: builtin-skills-enabled
- name: SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: skillhub-download-anon-cookie-secret
# Bootstrap Admin
- name: BOOTSTRAP_ADMIN_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: bootstrap-admin-enabled
- name: BOOTSTRAP_ADMIN_USER_ID
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: bootstrap-admin-user-id
- name: BOOTSTRAP_ADMIN_USERNAME
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: bootstrap-admin-username
- name: BOOTSTRAP_ADMIN_DISPLAY_NAME
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: bootstrap-admin-display-name
- name: BOOTSTRAP_ADMIN_EMAIL
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: bootstrap-admin-email
- name: BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: bootstrap-admin-password
optional: true
# OAuth2 GitHub (optional)
- name: OAUTH2_GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-github-client-id
optional: true
- name: OAUTH2_GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-github-client-secret
optional: true
{{- if .Values.server.javaOpts }}
- name: JAVA_OPTS
value: {{ .Values.server.javaOpts }}
{{- end }}
{{- with .Values.server.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if and .Values.server.enabled (not .Values.s3.enabled) }}
volumeMounts:
- name: skillhub-storage
mountPath: /var/lib/skillhub/storage
{{- end }}
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
startupProbe:
{{- toYaml .Values.server.probes.startup | nindent 12 }}
readinessProbe:
{{- toYaml .Values.server.probes.readiness | nindent 12 }}
livenessProbe:
{{- toYaml .Values.server.probes.liveness | nindent 12 }}
{{- if and .Values.server.enabled (not .Values.s3.enabled) }}
volumes:
- name: skillhub-storage
persistentVolumeClaim:
claimName: {{ include "skillhub.fullname" . }}-server-data
{{- end }}
{{- with .Values.server.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,61 @@
{{- /*
SkillHub Service 资源
- server/web: 使用组件自己的 service.type 配置(共享同一模板)
- scanner: 固定 ClusterIP仅供内部调用
*/}}
{{- range $name := list "server" "web" }}
{{- $component := index $.Values $name }}
{{- $enabled := true }}
{{- if hasKey $component "enabled" }}
{{- $enabled = $component.enabled }}
{{- end }}
{{- if and $enabled $component.service.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "skillhub.fullname" $ }}-{{ $name }}
labels:
{{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }}
spec:
type: {{ $component.service.type }}
{{- if eq $component.service.type "LoadBalancer" }}
{{- if $component.service.loadBalancerIP }}
loadBalancerIP: {{ $component.service.loadBalancerIP }}
{{- end }}
{{- if $component.service.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- toYaml $component.service.loadBalancerSourceRanges | nindent 4 }}
{{- end }}
{{- end }}
ports:
- name: http
port: {{ $component.service.port }}
targetPort: http
{{- if and (eq $component.service.type "NodePort") $component.service.nodePort }}
nodePort: {{ $component.service.nodePort }}
{{- end }}
selector:
{{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 4 }}
{{- end }}
{{- end }}
{{- /* Scanner Service固定 ClusterIP */}}
{{- if and .Values.scanner.enabled .Values.scanner.service }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "skillhub.fullname" . }}-scanner
labels:
{{- include "skillhub.scanner.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.scanner.service.port }}
targetPort: http
selector:
{{- include "skillhub.scanner.selectorLabels" . | nindent 4 }}
{{- end }}

View file

@ -0,0 +1,95 @@
{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}}
{{- $absoluteHttpUrlPattern := "^https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#][^[:space:]]*)?$" -}}
{{- if not .Values.server.enabled -}}
{{- fail "server.enabled=false is unsupported because the bundled web component requires the SkillHub server" -}}
{{- end -}}
{{- if and .Values.auth.direct.enabled (not .Values.auth.direct.provider) -}}
{{- fail "auth.direct.enabled=true requires auth.direct.provider" -}}
{{- end -}}
{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}}
{{- fail "ingress.enabled=true requires server.service.enabled=true" -}}
{{- end -}}
{{- if and .Values.ingress.enabled (not .Values.web.service.enabled) -}}
{{- fail "ingress.enabled=true requires web.service.enabled=true" -}}
{{- end -}}
{{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled (not .Values.ingress.tls) -}}
{{- fail "ingress.certManager.enabled=true requires at least one ingress.tls entry" -}}
{{- end -}}
{{- range $host := .Values.ingress.hosts -}}
{{- range $path := $host.paths -}}
{{- if regexMatch "^/(api|oauth2|login/oauth2|\\.well-known)(/|$)" $path.path -}}
{{- fail "ingress.hosts[].paths reserves /api, /oauth2, /login/oauth2 and /.well-known for the SkillHub server" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- range $name := list "server" "web" "scanner" -}}
{{- $component := index $.Values $name -}}
{{- $enabled := true -}}
{{- if hasKey $component "enabled" -}}
{{- $enabled = $component.enabled -}}
{{- end -}}
{{- if and $enabled $component.autoscaling.enabled -}}
{{- if gt ($component.autoscaling.minReplicas | int) ($component.autoscaling.maxReplicas | int) -}}
{{- fail (printf "%s.autoscaling.minReplicas must not exceed maxReplicas" $name) -}}
{{- end -}}
{{- if and (not $component.autoscaling.targetCPUUtilizationPercentage) (not $component.autoscaling.targetMemoryUtilizationPercentage) -}}
{{- fail (printf "%s.autoscaling requires at least one CPU or memory utilization target" $name) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $localStorageReplicas := .Values.server.replicaCount | int -}}
{{- if .Values.server.autoscaling.enabled -}}
{{- $localStorageReplicas = .Values.server.autoscaling.maxReplicas | int -}}
{{- end -}}
{{- if and (not .Values.s3.enabled) (gt $localStorageReplicas 1) -}}
{{- if not .Values.server.storage.accessMode -}}
{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany and an RWX-capable StorageClass; use S3 otherwise" -}}
{{- end -}}
{{- if ne .Values.server.storage.accessMode "ReadWriteMany" -}}
{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany" -}}
{{- end -}}
{{- end -}}
{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.host) -}}
{{- fail "postgresql.enabled=false requires externalDatabase.host for dependency checks" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.host) -}}
{{- fail "redis.enabled=false requires externalRedis.host" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled (not .Values.externalRedis.sentinel.nodes) -}}
{{- fail "external Redis Sentinel requires at least one externalRedis.sentinel.nodes entry" -}}
{{- end -}}
{{- if and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}}
{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}
{{- if and .Values.s3.publicEndpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.publicEndpoint)) -}}
{{- fail "s3.publicEndpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}
{{- if not .Values.secrets.allowAutoGenerated -}}
{{- if not .Values.existingSecret -}}
{{- if not (or .Values.secrets.bootstrapAdminPassword .Values.bootstrapAdmin.password) -}}
{{- fail "secrets.allowAutoGenerated=false requires secrets.bootstrapAdminPassword or bootstrapAdmin.password" -}}
{{- end -}}
{{- if not .Values.secrets.downloadAnonCookieSecret -}}
{{- fail "secrets.allowAutoGenerated=false requires secrets.downloadAnonCookieSecret" -}}
{{- end -}}
{{- end -}}
{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.existingSecret) -}}
{{- if and .Values.postgresql.auth.enablePostgresUser (not .Values.postgresql.auth.postgresPassword) -}}
{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.postgresPassword or postgresql.auth.existingSecret" -}}
{{- end -}}
{{- if and .Values.postgresql.auth.username (ne .Values.postgresql.auth.username "postgres") (not .Values.postgresql.auth.password) -}}
{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.password or postgresql.auth.existingSecret" -}}
{{- end -}}
{{- if and (eq .Values.postgresql.architecture "replication") (not .Values.postgresql.auth.replicationPassword) -}}
{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.replicationPassword for replication architecture" -}}
{{- end -}}
{{- end -}}
{{- if and .Values.redis.enabled .Values.redis.auth.enabled (not .Values.redis.auth.existingSecret) (not .Values.redis.auth.password) -}}
{{- fail "secrets.allowAutoGenerated=false requires redis.auth.password or redis.auth.existingSecret" -}}
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "skillhub.fullname" . }}-web
labels:
{{- include "skillhub.web.labels" . | nindent 4 }}
spec:
{{- if not .Values.web.autoscaling.enabled }}
replicas: {{ .Values.web.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "skillhub.web.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "skillhub.web.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ toYaml (dict "web" .Values.web "publicBaseUrl" .Values.publicBaseUrl "auth" .Values.auth) | sha256sum }}
{{- range $key, $val := .Values.web.podAnnotations }}
{{ $key }}: {{ $val }}
{{- end }}
spec:
{{- $secrets := .Values.web.imagePullSecrets }}
{{- if $secrets }}
imagePullSecrets:
{{- toYaml $secrets | nindent 8 }}
{{- end }}
containers:
- name: web
image: {{ .Values.web.image.registry | default .Values.images.registry }}/skillhub-web:{{ .Values.web.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }}
imagePullPolicy: {{ .Values.images.pullPolicy }}
env:
- name: SKILLHUB_API_UPSTREAM
value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.server.service.port }}
- name: SKILLHUB_PUBLIC_BASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: public-base-url
- name: SKILLHUB_WEB_AUTH_DIRECT_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-direct-enabled
- name: SKILLHUB_WEB_AUTH_DIRECT_PROVIDER
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-direct-provider
{{- with .Values.web.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: {{ .Values.web.service.port }}
name: http
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
readinessProbe:
{{- toYaml .Values.web.probes.readiness | nindent 12 }}
livenessProbe:
{{- toYaml .Values.web.probes.liveness | nindent 12 }}
{{- with .Values.web.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.web.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.web.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -euo pipefail
CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
TEST_VALUES="$CHART_DIR/tests/test-values.yaml"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "ERROR: $*" >&2
exit 1
}
render() {
helm template "$@" -f "$TEST_VALUES"
}
assert_rejected() {
local name=$1
shift
if render "$name" "$CHART_DIR" "$@" >"$TMP_DIR/$name.yaml" 2>"$TMP_DIR/$name.err"; then
fail "$name should have been rejected"
fi
}
render verify "$CHART_DIR" >"$TMP_DIR/default.yaml"
grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml"
grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml"
grep -Fq 'value: "verify-redis-master"' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/postgresql@sha256:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/postgres-exporter@sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis@sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis-exporter@sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773' "$TMP_DIR/default.yaml"
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/default.yaml"; then
fail "default workloads must not use mutable latest image tags"
fi
grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml"
grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
render custom-server-fsgroup "$CHART_DIR" \
--set server.podSecurityContext.fsGroup=2000 \
--set server.podSecurityContext.fsGroupChangePolicy=Always \
--show-only templates/server-deployment.yaml >"$TMP_DIR/custom-server-fsgroup.yaml"
grep -Fq 'fsGroup: 2000' "$TMP_DIR/custom-server-fsgroup.yaml"
grep -Fq 'fsGroupChangePolicy: Always' "$TMP_DIR/custom-server-fsgroup.yaml"
stable_args=(
--set-string secrets.bootstrapAdminPassword=stable-bootstrap-password
--set-string secrets.downloadAnonCookieSecret=stable-download-cookie-secret
--set-string postgresql.auth.postgresPassword=stable-postgres-password
--set-string postgresql.auth.password=stable-user-password
--set-string redis.auth.password=stable-redis-password
)
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-a.yaml"
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-b.yaml"
cmp "$TMP_DIR/stable-a.yaml" "$TMP_DIR/stable-b.yaml"
render private-registry "$CHART_DIR" \
--set server.dependencyWait.image.registry=registry.example.com \
--set server.dependencyWait.image.repository=library/busybox \
--show-only templates/server-deployment.yaml >"$TMP_DIR/private-registry.yaml"
grep -Fq 'image: "registry.example.com/library/busybox:1.37"' "$TMP_DIR/private-registry.yaml"
render postgresql-replication "$CHART_DIR" \
--set postgresql.architecture=replication >"$TMP_DIR/postgresql-replication.yaml"
if [[ $(grep -Fc 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/postgresql-replication.yaml") -ne 2 ]]; then
fail "PostgreSQL primary and read replica must use the same max_connections setting"
fi
render custom "$CHART_DIR" \
--set postgresql.auth.existingSecret=custom-pg \
--set postgresql.auth.secretKeys.userPasswordKey=custom-pg-key \
--set redis.auth.existingSecret=custom-redis \
--set redis.auth.existingSecretPasswordKey=custom-redis-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/custom.yaml"
grep -Fq 'name: custom-pg' "$TMP_DIR/custom.yaml"
grep -Fq 'key: custom-pg-key' "$TMP_DIR/custom.yaml"
grep -Fq 'name: custom-redis' "$TMP_DIR/custom.yaml"
grep -Fq 'key: custom-redis-key' "$TMP_DIR/custom.yaml"
render postgresql-admin "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin.yaml"
grep -Fq 'value: "postgres"' "$TMP_DIR/postgresql-admin.yaml"
grep -Fq 'key: postgres-password' "$TMP_DIR/postgresql-admin.yaml"
render postgresql-admin-secret "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--show-only charts/postgresql/templates/secrets.yaml >"$TMP_DIR/postgresql-admin-secret.yaml"
grep -Eq '^ postgres-password:' "$TMP_DIR/postgresql-admin-secret.yaml"
if grep -Eq '^ password:' "$TMP_DIR/postgresql-admin-secret.yaml"; then
fail "Bitnami PostgreSQL must not create a custom-user password key for username=postgres"
fi
render postgresql-admin-existing-secret "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--set postgresql.auth.existingSecret=custom-pg-admin \
--set postgresql.auth.secretKeys.adminPasswordKey=custom-admin-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin-existing-secret.yaml"
grep -Fq 'name: custom-pg-admin' "$TMP_DIR/postgresql-admin-existing-secret.yaml"
grep -Fq 'key: custom-admin-key' "$TMP_DIR/postgresql-admin-existing-secret.yaml"
render sentinel "$CHART_DIR" \
--set redis.architecture=replication \
--set redis.sentinel.enabled=true \
--show-only templates/server-deployment.yaml >"$TMP_DIR/sentinel.yaml"
grep -Fq 'value: "docker,redis-sentinel"' "$TMP_DIR/sentinel.yaml"
grep -Fq 'value: "mymaster"' "$TMP_DIR/sentinel.yaml"
grep -Fq '.svc.cluster.local:26379' "$TMP_DIR/sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/sentinel.yaml"
grep -A1 -F 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/sentinel.yaml" \
| grep -Fq 'value: "false"'
render sentinel-full "$CHART_DIR" \
--set redis.architecture=replication \
--set redis.sentinel.enabled=true >"$TMP_DIR/sentinel-full.yaml"
grep -Fq 'bitnami/redis-sentinel@sha256:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da' "$TMP_DIR/sentinel-full.yaml"
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/sentinel-full.yaml"; then
fail "Sentinel workloads must not use mutable latest image tags"
fi
render external-sentinel "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set externalRedis.password=redis-password \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.sentinel.password=sentinel-password \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a:26379","sentinel-b:26379"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-sentinel.yaml"
grep -Fq 'value: "sentinel-a"' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
if grep -Fq 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/external-sentinel.yaml"; then
fail "external Sentinel must preserve Redisson address consistency checks by default"
fi
render special "$CHART_DIR" \
--set-string 'bootstrapAdmin.displayName=Ops: Admin' \
--show-only templates/configmap.yaml >"$TMP_DIR/special.yaml"
grep -Fq 'bootstrap-admin-display-name: "Ops: Admin"' "$TMP_DIR/special.yaml"
render device "$CHART_DIR" \
--set publicBaseUrl=https://skills.example.com \
--show-only templates/configmap.yaml >"$TMP_DIR/device.yaml"
grep -Fq 'device-auth-verification-uri: "https://skills.example.com/cli/auth"' "$TMP_DIR/device.yaml"
render tls "$CHART_DIR" \
--set ingress.enabled=true \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/configmap.yaml >"$TMP_DIR/tls.yaml"
grep -Fq 'session-cookie-secure: "true"' "$TMP_DIR/tls.yaml"
render tls "$CHART_DIR" \
--set ingress.enabled=true \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/ingress.yaml >"$TMP_DIR/tls-ingress.yaml"
for server_path in /api /oauth2 /login/oauth2 /.well-known; do
grep -Fq -- "- path: $server_path" "$TMP_DIR/tls-ingress.yaml"
done
if [[ $(grep -Fc 'name: tls-skillhub-server' "$TMP_DIR/tls-ingress.yaml") -ne 4 ]]; then
fail "API and OAuth ingress paths must route directly to the SkillHub server"
fi
render legacy-ingress "$CHART_DIR" \
--set ingress.enabled=true \
--set-string ingress.className= \
--set-json 'ingress.annotations={"kubernetes.io/ingress.class":"alb","alb.ingress.kubernetes.io/listen-ports":"[{\"HTTPS\":6443}]"}' \
--show-only templates/ingress.yaml >"$TMP_DIR/legacy-ingress.yaml"
grep -Fq 'kubernetes.io/ingress.class: alb' "$TMP_DIR/legacy-ingress.yaml"
grep -Fq 'alb.ingress.kubernetes.io/listen-ports:' "$TMP_DIR/legacy-ingress.yaml"
if grep -Fq 'ingressClassName:' "$TMP_DIR/legacy-ingress.yaml"; then
fail "empty ingress.className must omit spec.ingressClassName"
fi
render multi-host-ingress "$CHART_DIR" \
--set ingress.enabled=true \
--set ingress.certManager.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills-a.example.com","paths":[{"path":"/","pathType":"Prefix"}]},{"host":"skills-b.example.com","paths":[{"path":"/portal","pathType":"Prefix"}]}]' \
--set-json 'ingress.tls=[{"hosts":["skills-a.example.com","skills-b.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/ingress.yaml \
--show-only templates/certificate.yaml >"$TMP_DIR/multi-host-ingress.yaml"
if [[ $(grep -Fc 'skills-a.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then
fail "first ingress host must be rendered in rule, TLS and Certificate"
fi
if [[ $(grep -Fc 'skills-b.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then
fail "second ingress host must be rendered in rule, TLS and Certificate"
fi
render scanner-off "$CHART_DIR" \
--set scanner.enabled=false \
--set scanner.autoscaling.enabled=true \
--set scanner.podDisruptionBudget.enabled=true >"$TMP_DIR/scanner-off.yaml"
if awk '
$1 == "kind:" { kind=$2 }
kind ~ /^(Deployment|Service|HorizontalPodAutoscaler|PodDisruptionBudget)$/ &&
$1 == "name:" && $2 == "scanner-off-skillhub-scanner" { found=1 }
END { exit found ? 0 : 1 }
' "$TMP_DIR/scanner-off.yaml"; then
fail "disabled scanner rendered workload resources"
fi
render multi-rwx "$CHART_DIR" \
--set server.replicaCount=2 \
--set server.storage.accessMode=ReadWriteMany >"$TMP_DIR/multi-rwx.yaml"
grep -Fq -- '- ReadWriteMany' "$TMP_DIR/multi-rwx.yaml"
grep -Fq 'type: RollingUpdate' "$TMP_DIR/multi-rwx.yaml"
render s3-rolling "$CHART_DIR" \
--set s3.enabled=true \
--set s3.bucket=skillhub \
--set s3.endpoint=https://s3.example.com \
--set s3.accessKey=access-key \
--set s3.secretKey=secret-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/s3-rolling.yaml"
grep -Fq 'type: RollingUpdate' "$TMP_DIR/s3-rolling.yaml"
assert_rejected server-off --set server.enabled=false
assert_rejected direct-auth-without-provider \
--set auth.direct.enabled=true \
--set-string auth.direct.provider=
assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false
assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false
assert_rejected multi-without-rwx --set server.replicaCount=2
assert_rejected hpa-without-metrics \
--set server.autoscaling.enabled=true \
--set server.autoscaling.targetCPUUtilizationPercentage=0 \
--set server.autoscaling.targetMemoryUtilizationPercentage=0
assert_rejected old-postgres-env --set-json 'postgresql.primary.extraEnv=[{"name":"X","value":"Y"}]'
assert_rejected old-sentinel-password --set redis.auth.sentinelPassword=unused
assert_rejected old-sentinel-nodes --set redis.sentinel.nodes=unused
assert_rejected old-sentinel-service-switch --set redis.sentinel.service.enabled=false
assert_rejected invalid-fullname --set fullnameOverride=INVALID_NAME
assert_rejected old-ingress-host --set ingress.host=old.example.com
assert_rejected old-ingress-tls-object --set ingress.tls.enabled=true
assert_rejected reserved-oauth-ingress-path \
--set ingress.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/oauth2","pathType":"Prefix"}]}]'
assert_rejected reserved-oauth-ingress-child-path \
--set ingress.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/login/oauth2/code/github","pathType":"Prefix"}]}]'
assert_rejected invalid-s3-endpoint --set s3.endpoint=s3.amazonaws.com
assert_rejected invalid-s3-public-endpoint --set s3.publicEndpoint=cdn.example.com
assert_rejected invalid-s3-empty-authority --set-string 's3.endpoint=https://?'
assert_rejected invalid-s3-whitespace-authority --set-string 's3.publicEndpoint=https:// '
assert_rejected empty-ingress-hosts --set-json 'ingress.hosts=[]'
assert_rejected cert-manager-without-tls \
--set ingress.enabled=true \
--set ingress.certManager.enabled=true \
--set-json 'ingress.tls=[]'
if helm template missing-credentials "$CHART_DIR" >"$TMP_DIR/missing-credentials.yaml" 2>"$TMP_DIR/missing-credentials.err"; then
fail "default rendering without stable credentials should have been rejected"
fi
echo "Helm configuration contract tests passed"

View file

@ -0,0 +1,318 @@
#!/usr/bin/env bash
set -euo pipefail
CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
TEST_VALUES="$CHART_DIR/tests/test-values.yaml"
SCENARIO=${HELM_SMOKE_SCENARIO:-default}
NAMESPACE=${HELM_SMOKE_NAMESPACE:-skillhub-helm-smoke-$SCENARIO}
RELEASE=${HELM_SMOKE_RELEASE:-skillhub-smoke}
TIMEOUT=${HELM_SMOKE_TIMEOUT:-15m}
KEEP_ENVIRONMENT=${KEEP_HELM_SMOKE:-false}
TMP_DIR=$(mktemp -d)
PORT_FORWARD_PID=""
OWNS_NAMESPACE=false
HELM_SCENARIO_ARGS=()
fail() {
echo "ERROR: $*" >&2
exit 1
}
for command in helm kubectl curl jq sha256sum; do
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
done
if kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
fail "namespace $NAMESPACE already exists; choose an unused HELM_SMOKE_NAMESPACE"
fi
stop_port_forward() {
if [[ -n "$PORT_FORWARD_PID" ]]; then
kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
wait "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
PORT_FORWARD_PID=""
fi
}
cleanup() {
local exit_code=$?
trap - EXIT
stop_port_forward
if (( exit_code != 0 )) && kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
echo "Helm smoke failed; collecting non-secret diagnostics" >&2
helm status "$RELEASE" --namespace "$NAMESPACE" >&2 || true
kubectl get pods,pvc,deployments,statefulsets --namespace "$NAMESPACE" -o wide >&2 || true
kubectl get events --namespace "$NAMESPACE" --sort-by=.lastTimestamp >&2 || true
fi
if [[ "$KEEP_ENVIRONMENT" != "true" && "$OWNS_NAMESPACE" == "true" ]]; then
helm uninstall "$RELEASE" --namespace "$NAMESPACE" --wait >/dev/null 2>&1 || true
kubectl delete namespace "$NAMESPACE" --wait --timeout=5m >/dev/null 2>&1 || true
fi
rm -rf "$TMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT
setup_scenario() {
case "$SCENARIO" in
default)
;;
sentinel)
HELM_SCENARIO_ARGS+=(
--set redis.architecture=replication
--set redis.sentinel.enabled=true
)
;;
s3)
kubectl apply --namespace "$NAMESPACE" -f - <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
spec:
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
containers:
- name: minio
image: docker.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
args:
- server
- /data
env:
- name: MINIO_ROOT_USER
value: smoke-access-key
- name: MINIO_ROOT_PASSWORD
value: smoke-secret-key
ports:
- name: api
containerPort: 9000
readinessProbe:
httpGet:
path: /minio/health/ready
port: api
periodSeconds: 2
---
apiVersion: v1
kind: Service
metadata:
name: minio
spec:
selector:
app: minio
ports:
- name: api
port: 9000
targetPort: api
YAML
kubectl rollout status deployment/minio \
--namespace "$NAMESPACE" \
--timeout=5m
HELM_SCENARIO_ARGS+=(
--set s3.enabled=true
--set-string s3.endpoint=http://minio:9000
--set-string s3.accessKey=smoke-access-key
--set-string s3.secretKey=smoke-secret-key
--set s3.autoCreateBucket=true
)
;;
ingress-tls)
command -v openssl >/dev/null 2>&1 || fail "openssl is required for ingress-tls"
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$TMP_DIR/tls.key" \
-out "$TMP_DIR/tls.crt" \
-days 1 \
-subj /CN=skillhub-smoke.local \
-addext subjectAltName=DNS:skillhub-smoke.local >/dev/null 2>&1
kubectl create secret tls skillhub-smoke-tls \
--namespace "$NAMESPACE" \
--cert "$TMP_DIR/tls.crt" \
--key "$TMP_DIR/tls.key"
HELM_SCENARIO_ARGS+=(
--set ingress.enabled=true
--set-json 'ingress.hosts=[{"host":"skillhub-smoke.local","paths":[{"path":"/","pathType":"Prefix"}]}]'
--set-json 'ingress.tls=[{"hosts":["skillhub-smoke.local"],"secretName":"skillhub-smoke-tls"}]'
)
;;
*)
fail "unknown HELM_SMOKE_SCENARIO: $SCENARIO"
;;
esac
}
assert_scenario_contract() {
case "$SCENARIO" in
default)
;;
sentinel)
kubectl get deployment "$RELEASE-server" --namespace "$NAMESPACE" -o json \
| jq -e '
[.spec.template.spec.containers[]
| select(.name == "server")
| .env[]
| select(.name == "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST")
| .value] == ["false"]
' >/dev/null \
|| fail "Sentinel scenario did not apply the Kubernetes-only address-check override"
;;
s3)
local storage_provider
storage_provider=$(kubectl get configmap "$RELEASE-config" \
--namespace "$NAMESPACE" -o json | jq -r '.data["skillhub-storage-provider"]')
[[ "$storage_provider" == "s3" ]] || fail "S3 scenario did not configure S3 storage"
;;
ingress-tls)
kubectl get ingress "$RELEASE" --namespace "$NAMESPACE" -o json \
| jq -e --arg server "$RELEASE-server" '
.spec.tls[0].secretName == "skillhub-smoke-tls"
and (
[.spec.rules[].http.paths[]
| select(
.path == "/api"
or .path == "/oauth2"
or .path == "/login/oauth2"
or .path == "/.well-known"
)
| .backend.service.name]
| length == 4 and all(. == $server)
)
' >/dev/null \
|| fail "TLS Ingress does not route every reserved path directly to the server"
local cookie_secure
cookie_secure=$(kubectl get configmap "$RELEASE-config" \
--namespace "$NAMESPACE" -o json | jq -r '.data["session-cookie-secure"]')
[[ "$cookie_secure" == "true" ]] || fail "TLS Ingress did not enable secure session cookies"
;;
esac
}
probe_service() {
local service=$1
local service_port=$2
local local_port=$3
local path=$4
local expected_status=${5:-200}
local log_file="$TMP_DIR/${service}.port-forward.log"
local status
stop_port_forward
kubectl port-forward \
--namespace "$NAMESPACE" \
"service/$service" \
"$local_port:$service_port" >"$log_file" 2>&1 &
PORT_FORWARD_PID=$!
for _ in $(seq 1 60); do
status=$(curl --silent --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:$local_port$path" 2>/dev/null || true)
if [[ "$status" == "$expected_status" ]]; then
stop_port_forward
return 0
fi
if ! kill -0 "$PORT_FORWARD_PID" >/dev/null 2>&1; then
break
fi
sleep 1
done
cat "$log_file" >&2
fail "$service$path did not return HTTP $expected_status"
}
snapshot_secrets() {
local output=$1
: >"$output"
for secret in "$RELEASE-secret" "$RELEASE-postgresql" "$RELEASE-redis"; do
printf '%s ' "$secret" >>"$output"
kubectl get secret "$secret" --namespace "$NAMESPACE" -o json \
| jq -cS '.data' \
| sha256sum \
| awk '{print $1}' >>"$output"
done
}
snapshot_pvcs() {
local output=$1
kubectl get pvc --namespace "$NAMESPACE" -o json \
| jq -r '.items[] | [.metadata.name, .metadata.uid, .spec.volumeName] | @tsv' \
| sort >"$output"
[[ -s "$output" ]] || fail "Helm install did not create any PVCs"
}
assert_ready_and_healthy() {
kubectl wait pod \
--namespace "$NAMESPACE" \
--all \
--for=condition=Ready \
--timeout="$TIMEOUT"
probe_service "$RELEASE-server" 8080 18081 /actuator/health
probe_service "$RELEASE-web" 80 18080 /nginx-health
probe_service "$RELEASE-web" 80 18080 /api/v1/auth/me 401
probe_service "$RELEASE-scanner" 8000 18082 /health
local restarts
restarts=$(kubectl get pods --namespace "$NAMESPACE" -o json \
| jq '[.items[].status.containerStatuses[]?.restartCount] | add // 0')
[[ "$restarts" == "0" ]] || fail "workloads restarted $restarts time(s)"
}
helm dependency build "$CHART_DIR"
kubectl create namespace "$NAMESPACE"
OWNS_NAMESPACE=true
setup_scenario
helm install "$RELEASE" "$CHART_DIR" \
--namespace "$NAMESPACE" \
--values "$TEST_VALUES" \
--set-string fullnameOverride="$RELEASE" \
--set-string publicBaseUrl=http://skillhub-smoke.local \
"${HELM_SCENARIO_ARGS[@]}" \
--wait \
--timeout "$TIMEOUT"
assert_ready_and_healthy
assert_scenario_contract
snapshot_secrets "$TMP_DIR/secrets-before"
snapshot_pvcs "$TMP_DIR/pvcs-before"
revision_before=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
| jq -r '.[-1].revision')
helm upgrade "$RELEASE" "$CHART_DIR" \
--namespace "$NAMESPACE" \
--reuse-values \
--set-string publicBaseUrl=https://skillhub-smoke.local \
--set-string server.podAnnotations.helm-smoke-revision=revision-2 \
--wait \
--timeout "$TIMEOUT"
assert_ready_and_healthy
assert_scenario_contract
snapshot_secrets "$TMP_DIR/secrets-after"
snapshot_pvcs "$TMP_DIR/pvcs-after"
revision_after=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
| jq -r '.[-1].revision')
(( revision_after == revision_before + 1 )) \
|| fail "Helm revision did not advance exactly once"
cmp "$TMP_DIR/secrets-before" "$TMP_DIR/secrets-after" \
|| fail "application or dependency Secret data changed during upgrade"
cmp "$TMP_DIR/pvcs-before" "$TMP_DIR/pvcs-after" \
|| fail "PVC identity or bound volume changed during upgrade"
public_base_url=$(kubectl get configmap "$RELEASE-config" \
--namespace "$NAMESPACE" \
-o json | jq -r '.data["public-base-url"]')
[[ "$public_base_url" == "https://skillhub-smoke.local" ]] \
|| fail "publicBaseUrl was not applied by the upgrade"
echo "Helm install/upgrade smoke passed for scenario: $SCENARIO"

View file

@ -0,0 +1,14 @@
# Non-production credentials used only for deterministic chart tests.
secrets:
bootstrapAdminPassword: test-bootstrap-password
downloadAnonCookieSecret: test-download-cookie-secret-at-least-32-chars
postgresql:
auth:
postgresPassword: test-postgres-password
password: test-postgresql-user-password
replicationPassword: test-postgresql-replication-password
redis:
auth:
password: test-redis-password

View file

@ -0,0 +1,462 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"global": { "type": "object" },
"images": {
"type": "object",
"additionalProperties": false,
"required": ["registry", "tag", "pullPolicy"],
"properties": {
"registry": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"nameOverride": { "$ref": "#/definitions/optionalDnsLabel" },
"fullnameOverride": { "$ref": "#/definitions/optionalDnsLabel" },
"publicBaseUrl": { "type": "string" },
"deviceAuthVerificationUri": { "type": "string" },
"auth": {
"type": "object",
"additionalProperties": false,
"required": ["direct"],
"properties": {
"direct": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "provider"],
"properties": {
"enabled": { "type": "boolean" },
"provider": { "type": "string" }
}
}
}
},
"builtinSkills": {
"type": "object",
"additionalProperties": false,
"required": ["enabled"],
"properties": { "enabled": { "type": "boolean" } }
},
"ingress": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "className", "hosts", "annotations", "tls", "certManager"],
"properties": {
"enabled": { "type": "boolean" },
"className": {
"oneOf": [
{ "type": "string", "enum": [""] },
{ "$ref": "#/definitions/dnsSubdomain" }
]
},
"hosts": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["host", "paths"],
"properties": {
"host": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" },
"paths": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "pathType"],
"properties": {
"path": { "type": "string", "pattern": "^/" },
"pathType": { "enum": ["Exact", "Prefix", "ImplementationSpecific"] }
}
}
}
}
}
},
"annotations": { "$ref": "#/definitions/stringMap" },
"tls": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["hosts", "secretName"],
"properties": {
"hosts": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" }
},
"secretName": { "type": "string", "minLength": 1 }
}
}
},
"certManager": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "issuerName", "issuerKind"],
"properties": {
"enabled": { "type": "boolean" },
"issuerName": { "type": "string", "minLength": 1 },
"issuerKind": { "type": "string", "minLength": 1 }
}
}
}
},
"s3": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "bucket", "endpoint", "publicEndpoint", "region", "forcePathStyle", "disableChunkedEncoding", "autoCreateBucket", "presignExpiry", "accessKey", "secretKey"],
"properties": {
"enabled": { "type": "boolean" },
"bucket": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
"publicEndpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
"region": { "type": "string", "minLength": 1 },
"forcePathStyle": { "type": "boolean" },
"disableChunkedEncoding": { "type": "boolean" },
"autoCreateBucket": { "type": "boolean" },
"presignExpiry": { "type": "string", "pattern": "^P" },
"accessKey": { "type": "string" },
"secretKey": { "type": "string" }
}
},
"session": {
"type": "object",
"additionalProperties": false,
"required": ["cookieSecure"],
"properties": { "cookieSecure": { "type": "boolean" } }
},
"bootstrapAdmin": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "userId", "username", "displayName", "email", "password"],
"properties": {
"enabled": { "type": "boolean" },
"userId": { "type": "string", "minLength": 1 },
"username": { "type": "string", "minLength": 1 },
"displayName": { "type": "string", "minLength": 1 },
"email": { "type": "string", "minLength": 1 },
"password": { "type": "string" }
}
},
"springProfilesActive": { "type": "string", "minLength": 1 },
"existingSecret": { "type": "string" },
"secrets": {
"type": "object",
"additionalProperties": false,
"required": ["allowAutoGenerated"],
"properties": {
"allowAutoGenerated": { "type": "boolean" },
"bootstrapAdminPassword": { "type": "string" },
"downloadAnonCookieSecret": { "type": "string" },
"oauth2GithubClientId": { "type": "string" },
"oauth2GithubClientSecret": { "type": "string" },
"scannerLlmApiKey": { "type": "string" },
"scannerLlmBaseUrl": { "type": "string" },
"scannerLlmModel": { "type": "string" }
}
},
"postgresql": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"architecture": { "enum": ["standalone", "replication"] },
"auth": { "type": "object" },
"primary": {
"type": "object",
"properties": { "extraEnv": false }
}
}
},
"externalDatabase": {
"type": "object",
"additionalProperties": false,
"required": ["host", "port", "database", "username", "password", "jdbcUrl"],
"properties": {
"host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"database": { "type": "string", "minLength": 1 },
"username": { "type": "string", "minLength": 1 },
"password": { "type": "string" },
"jdbcUrl": { "type": "string" }
}
},
"redis": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"architecture": { "enum": ["standalone", "replication"] },
"auth": {
"type": "object",
"properties": { "sentinelPassword": false }
},
"sentinel": {
"type": "object",
"properties": {
"nodes": false,
"service": {
"type": "object",
"properties": { "enabled": false }
}
}
}
}
},
"externalRedis": {
"type": "object",
"additionalProperties": false,
"required": ["host", "port", "password", "sentinel"],
"properties": {
"host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"password": { "type": "string" },
"sentinel": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "masterSet", "nodes", "password"],
"properties": {
"enabled": { "type": "boolean" },
"masterSet": { "type": "string", "minLength": 1 },
"nodes": {
"type": "array",
"items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" }
},
"password": { "type": "string" }
}
}
}
},
"server": { "$ref": "#/definitions/serverComponent" },
"web": { "$ref": "#/definitions/webComponent" },
"scanner": { "$ref": "#/definitions/scannerComponent" }
},
"required": ["images", "auth", "builtinSkills", "ingress", "s3", "session", "bootstrapAdmin", "springProfilesActive", "secrets", "postgresql", "externalDatabase", "redis", "externalRedis", "server", "web", "scanner"],
"definitions": {
"dnsLabel": {
"type": "string",
"minLength": 1,
"maxLength": 63,
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
},
"dnsSubdomain": {
"type": "string",
"minLength": 1,
"maxLength": 253,
"pattern": "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$"
},
"optionalDnsLabel": {
"type": "string",
"maxLength": 63,
"pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
},
"stringMap": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"image": {
"type": "object",
"additionalProperties": false,
"required": ["registry", "tag"],
"properties": {
"registry": { "type": "string" },
"tag": { "type": "string" }
}
},
"service": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "type", "port", "nodePort", "loadBalancerIP", "loadBalancerSourceRanges"],
"properties": {
"enabled": { "type": "boolean" },
"type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"nodePort": {
"oneOf": [
{ "type": "string", "enum": [""] },
{ "type": "integer", "minimum": 1, "maximum": 65535 }
]
},
"loadBalancerIP": { "type": "string" },
"loadBalancerSourceRanges": {
"type": "array",
"items": { "type": "string" }
}
}
},
"resources": { "type": "object" },
"autoscaling": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage", "targetMemoryUtilizationPercentage"],
"properties": {
"enabled": { "type": "boolean" },
"minReplicas": { "type": "integer", "minimum": 1 },
"maxReplicas": { "type": "integer", "minimum": 1 },
"targetCPUUtilizationPercentage": { "type": "integer", "minimum": 0 },
"targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 0 }
}
},
"pdb": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "minAvailable"],
"properties": {
"enabled": { "type": "boolean" },
"minAvailable": {
"oneOf": [
{ "type": "integer", "minimum": 0 },
{ "type": "string", "pattern": "^[0-9]+%$" }
]
}
}
},
"commonPod": {
"type": "object",
"properties": {
"resources": { "$ref": "#/definitions/resources" },
"extraEnv": { "type": "array", "items": { "type": "object" } },
"podAnnotations": { "$ref": "#/definitions/stringMap" },
"imagePullSecrets": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": { "name": { "type": "string", "minLength": 1 } }
}
},
"nodeSelector": { "$ref": "#/definitions/stringMap" },
"tolerations": { "type": "array", "items": { "type": "object" } },
"affinity": { "type": "object" },
"probes": { "type": "object" },
"autoscaling": { "$ref": "#/definitions/autoscaling" },
"podDisruptionBudget": { "$ref": "#/definitions/pdb" }
}
},
"serverComponent": {
"allOf": [
{ "$ref": "#/definitions/commonPod" },
{
"type": "object",
"additionalProperties": false,
"required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": { "type": "integer", "minimum": 1 },
"image": { "$ref": "#/definitions/image" },
"dependencyWait": {
"type": "object",
"additionalProperties": false,
"required": ["image"],
"properties": {
"image": {
"type": "object",
"additionalProperties": false,
"required": ["registry", "repository", "tag", "pullPolicy"],
"properties": {
"registry": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string", "minLength": 1 },
"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
}
}
}
},
"service": { "$ref": "#/definitions/service" },
"storage": {
"type": "object",
"additionalProperties": false,
"required": ["accessMode", "size", "storageClassName"],
"properties": {
"accessMode": { "enum": ["", "ReadWriteOnce", "ReadWriteMany"] },
"size": { "type": "string", "minLength": 1 },
"storageClassName": { "type": "string" }
}
},
"podSecurityContext": {
"type": "object",
"additionalProperties": false,
"required": ["fsGroup", "fsGroupChangePolicy"],
"properties": {
"fsGroup": { "type": "integer", "minimum": 1 },
"fsGroupChangePolicy": { "enum": ["Always", "OnRootMismatch"] }
}
},
"resources": { "$ref": "#/definitions/resources" },
"javaOpts": { "type": "string" },
"extraEnv": { "type": "array", "items": { "type": "object" } },
"podAnnotations": { "$ref": "#/definitions/stringMap" },
"imagePullSecrets": { "type": "array", "items": { "type": "object" } },
"nodeSelector": { "$ref": "#/definitions/stringMap" },
"tolerations": { "type": "array", "items": { "type": "object" } },
"affinity": { "type": "object" },
"probes": { "type": "object" },
"autoscaling": { "$ref": "#/definitions/autoscaling" },
"podDisruptionBudget": { "$ref": "#/definitions/pdb" }
}
}
]
},
"webComponent": {
"allOf": [
{ "$ref": "#/definitions/commonPod" },
{
"type": "object",
"additionalProperties": false,
"required": ["replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"image": { "$ref": "#/definitions/image" },
"service": { "$ref": "#/definitions/service" },
"resources": { "$ref": "#/definitions/resources" },
"extraEnv": { "type": "array", "items": { "type": "object" } },
"podAnnotations": { "$ref": "#/definitions/stringMap" },
"imagePullSecrets": { "type": "array", "items": { "type": "object" } },
"nodeSelector": { "$ref": "#/definitions/stringMap" },
"tolerations": { "type": "array", "items": { "type": "object" } },
"affinity": { "type": "object" },
"probes": { "type": "object" },
"autoscaling": { "$ref": "#/definitions/autoscaling" },
"podDisruptionBudget": { "$ref": "#/definitions/pdb" }
}
}
]
},
"scannerComponent": {
"allOf": [
{ "$ref": "#/definitions/commonPod" },
{
"type": "object",
"additionalProperties": false,
"required": ["enabled", "replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": { "type": "integer", "minimum": 1 },
"image": { "$ref": "#/definitions/image" },
"service": {
"type": "object",
"additionalProperties": false,
"required": ["port"],
"properties": { "port": { "type": "integer", "minimum": 1, "maximum": 65535 } }
},
"resources": { "$ref": "#/definitions/resources" },
"extraEnv": { "type": "array", "items": { "type": "object" } },
"podAnnotations": { "$ref": "#/definitions/stringMap" },
"imagePullSecrets": { "type": "array", "items": { "type": "object" } },
"nodeSelector": { "$ref": "#/definitions/stringMap" },
"tolerations": { "type": "array", "items": { "type": "object" } },
"affinity": { "type": "object" },
"probes": { "type": "object" },
"autoscaling": { "$ref": "#/definitions/autoscaling" },
"podDisruptionBudget": { "$ref": "#/definitions/pdb" }
}
}
]
}
}
}

460
charts/skillhub/values.yaml Normal file
View file

@ -0,0 +1,460 @@
# ============================================================================
# SkillHub Helm Chart 全局配置
# ============================================================================
# ============================================================================
# 镜像配置
# ============================================================================
images:
registry: ghcr.io/iflytek
tag: ""
pullPolicy: IfNotPresent
nameOverride: ""
fullnameOverride: ""
# 浏览器、CLI 和 OAuth 回调访问的公开地址(不带末尾斜杠)
publicBaseUrl: ""
deviceAuthVerificationUri: ""
auth:
direct:
enabled: true
provider: local
builtinSkills:
enabled: true
# ============================================================================
# Ingress 配置
# ============================================================================
ingress:
enabled: false
className: nginx
hosts:
- host: skills.example.com
paths:
- path: /
pathType: Prefix
annotations: {}
tls: []
certManager:
enabled: false
issuerName: letsencrypt-prod
issuerKind: ClusterIssuer
# ============================================================================
# S3 对象存储配置
# ============================================================================
s3:
enabled: false
bucket: skillhub-storage
endpoint: ""
publicEndpoint: ""
region: us-east-1
forcePathStyle: true
disableChunkedEncoding: false
autoCreateBucket: false
presignExpiry: PT10M
accessKey: ""
secretKey: ""
# ============================================================================
# Session 配置
# ============================================================================
session:
cookieSecure: false
# ============================================================================
# Bootstrap 管理员
# ============================================================================
bootstrapAdmin:
enabled: true
userId: docker-admin
username: admin
displayName: "Platform Admin"
email: admin@example.com
password: ""
# ============================================================================
# Spring Profiles
# ============================================================================
springProfilesActive: docker
# ============================================================================
# Secret 配置
# ============================================================================
existingSecret: ""
secrets:
# 默认禁止随机 Secret仅在非 GitOps 临时环境中按需启用
allowAutoGenerated: false
bootstrapAdminPassword: ""
downloadAnonCookieSecret: ""
oauth2GithubClientId: ""
oauth2GithubClientSecret: ""
scannerLlmApiKey: ""
scannerLlmBaseUrl: ""
scannerLlmModel: ""
# ============================================================================
# PostgreSQL 配置Bitnami
# ============================================================================
postgresql:
enabled: true
architecture: standalone
# Bitnami's current chart defaults to a mutable latest tag. Pin the image
# digest so installs and rollbacks remain reproducible.
image:
digest: "sha256:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f"
auth:
postgresPassword: ""
database: skillhub
username: skillhub
password: ""
primary:
persistence:
enabled: true
size: 10Gi
storageClass: ""
accessModes:
- ReadWriteOnce
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 1Gi
extraEnvVars:
- name: POSTGRESQL_MAX_CONNECTIONS
value: "500"
podAnnotations: {}
podSecurityContext:
enabled: true
fsGroup: 1001
containerSecurityContext:
enabled: true
runAsUser: 1001
livenessProbe:
enabled: true
initialDelaySeconds: 30
periodSeconds: 20
readinessProbe:
enabled: true
initialDelaySeconds: 5
periodSeconds: 10
readReplicas:
persistence:
enabled: true
size: 10Gi
storageClass: ""
accessModes:
- ReadWriteOnce
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 1Gi
# Hot standbys must not use a lower max_connections than the primary.
extraEnvVars:
- name: POSTGRESQL_MAX_CONNECTIONS
value: "500"
metrics:
enabled: true
image:
digest: "sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b"
serviceMonitor:
enabled: false
externalDatabase:
host: ""
port: 5432
database: skillhub
username: skillhub
password: ""
jdbcUrl: ""
# ============================================================================
# Redis 配置Bitnami
# ============================================================================
redis:
enabled: true
architecture: standalone
# Keep the bundled Redis runtime immutable for repeatable upgrades.
image:
digest: "sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98"
auth:
enabled: true
password: ""
master:
persistence:
enabled: true
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 512Mi
podAnnotations: {}
podSecurityContext:
enabled: true
fsGroup: 1001
containerSecurityContext:
enabled: true
runAsUser: 1001
replica:
persistence:
enabled: true
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 512Mi
sentinel:
enabled: false
masterSet: mymaster
image:
digest: "sha256:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da"
service:
ports:
sentinel: 26379
containerPorts:
sentinel: 26379
metrics:
enabled: true
image:
digest: "sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773"
serviceMonitor:
enabled: false
externalRedis:
host: ""
port: 6379
password: ""
sentinel:
enabled: false
masterSet: mymaster
nodes: []
password: ""
# ============================================================================
# Server 配置
# ============================================================================
server:
enabled: true
replicaCount: 1
image:
registry: ""
tag: ""
dependencyWait:
image:
registry: docker.io
repository: library/busybox
tag: "1.37"
pullPolicy: IfNotPresent
service:
enabled: true
type: ClusterIP
port: 8080
nodePort: ""
loadBalancerIP: ""
loadBalancerSourceRanges: []
storage:
# 访问模式:留空时自动判断(单副本 RWO多副本 RWX或手动指定
accessMode: ""
size: 10Gi
storageClassName: ""
# PVC 挂载会覆盖镜像内目录权限;使用镜像中 app 用户的组 ID 使本地存储可写
podSecurityContext:
fsGroup: 101
fsGroupChangePolicy: OnRootMismatch
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
javaOpts: ""
extraEnv: []
podAnnotations: {}
imagePullSecrets: []
nodeSelector: {}
tolerations: []
affinity: {}
probes:
startup:
httpGet:
path: /actuator/health
port: http
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 30
readiness:
httpGet:
path: /actuator/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
liveness:
httpGet:
path: /actuator/health
port: http
initialDelaySeconds: 30
periodSeconds: 15
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
podDisruptionBudget:
enabled: false
minAvailable: 1
# ============================================================================
# Web 配置
# ============================================================================
web:
replicaCount: 1
image:
registry: ""
tag: ""
service:
enabled: true
type: ClusterIP
port: 80
nodePort: ""
loadBalancerIP: ""
loadBalancerSourceRanges: []
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
extraEnv: []
podAnnotations: {}
imagePullSecrets: []
nodeSelector: {}
tolerations: []
affinity: {}
probes:
readiness:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 5
periodSeconds: 10
liveness:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 10
periodSeconds: 15
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
podDisruptionBudget:
enabled: false
minAvailable: 1
# ============================================================================
# Scanner 配置
# ============================================================================
scanner:
enabled: true
replicaCount: 1
image:
registry: ""
tag: ""
service:
port: 8000
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
extraEnv: []
podAnnotations: {}
imagePullSecrets: []
nodeSelector: {}
tolerations: []
affinity: {}
probes:
readiness:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
liveness:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 15
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
podDisruptionBudget:
enabled: false
minAvailable: 1

20
cli/CHANGELOG.md Normal file
View file

@ -0,0 +1,20 @@
# Changelog
All notable CLI behavior changes are documented in this file.
## Unreleased
### Fixed
- Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug`
coordinates against their declared namespace instead of silently falling
back to `global`.
- Reject a namespaced coordinate combined with a conflicting `--namespace`
value; a matching value remains valid.
- Limit local removal with a namespaced coordinate or explicit `--namespace`
to the matching namespace, preventing collateral deletion of same-slug
installations in other namespaces. Bare-slug removal retains its existing
cross-namespace behavior for compatibility.
- Preserve public registry `msg` and `requestId` fields for unsuccessful
responses. HTTP 403 without a public message now reports the neutral
`access denied` fallback instead of assuming the token lacks scope.

View file

@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co
# Keyword search
skillhub search pdf
# Search with a one-off token
skillhub search pdf --token sk_xxx
# List all skills (empty query)
skillhub search "" --limit 50
@ -123,15 +126,34 @@ Output format: `namespace/slug version summary`
## 📥 Install Skills
The install coordinate accepts a bare slug or any of the equivalent namespace
forms below:
| Coordinate | Resolved namespace | Resolved slug |
|------------|--------------------|---------------|
| `my-skill` | `global` | `my-skill` |
| `team/my-skill` | `team` | `my-skill` |
| `@team/my-skill` | `team` | `my-skill` |
| `team--my-skill` | `team` | `my-skill` |
For a bare slug, `--namespace team` selects a non-global namespace. A
namespaced coordinate may be combined with the same `--namespace` value, but a
conflicting value is rejected instead of silently overriding the coordinate.
```bash
# Install to auto-detected Agent directory
skillhub install pdf-parser
# Equivalent namespaced coordinates
skillhub install team/my-skill
skillhub install @team/my-skill
skillhub install team--my-skill
# Choose install scope explicitly
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# Specify namespace (default: global)
# Specify namespace for a bare slug (default: global)
skillhub install pdf-parser --namespace myspace
# Specify version
@ -157,7 +179,7 @@ The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
@ -188,7 +210,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation
@ -236,9 +258,17 @@ skillhub list --json
### Remove Skills
```bash
# Remove all local installation targets
# A bare slug removes matching local installations across namespaces
skillhub remove pdf-parser
# A namespaced coordinate removes only that namespace
skillhub remove myspace/pdf-parser
skillhub remove @myspace/pdf-parser
skillhub remove myspace--pdf-parser
# Equivalent precise local removal with an explicit namespace
skillhub remove pdf-parser --namespace myspace
# Remove only specific Agent's installation
skillhub remove pdf-parser --agent codex
@ -333,10 +363,10 @@ Update mechanism:
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <slug> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `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 list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |
| `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 |
| `skillhub publish <path> [--namespace <slug>] [--visibility <v>] [--registry <url>] [--token <token>] [--json]` | Publish a skill |
| `skillhub update [--check] [--json]` | Check or execute CLI self-update |
@ -361,6 +391,11 @@ skillhub whoami
skillhub login --token sk_xxx
```
For structured registry failures, the CLI prints the server's public `msg` and
`requestId`. HTTP 403 without a public message falls back to `access denied`;
it is not automatically described as a missing token scope. Include the
request ID when asking a registry operator to investigate.
### Network Error
```bash

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.7",
"version": "0.1.9",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",
@ -28,6 +28,7 @@
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {

View file

@ -1,7 +1,7 @@
import { homedir } from 'node:os'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { pathExists } from '../platform/paths'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from './types'
import { allProfiles, profileMap } from './detector'
@ -66,7 +66,19 @@ async function resolveScopedTargets(
} else {
candidates = await generateScopedCandidates(scope, options.cwd, scopedHome)
}
candidates = dedupeByRoot(candidates)
candidates = await dedupeByRoot(candidates)
if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) {
candidates = await dedupeByRoot([
...candidates,
{
agent: 'generic',
rootDir: `${scopedHome}/.agents/skills`,
scope: 'user',
source: 'fallback'
}
])
}
if (candidates.length === 0) {
const fallbackRoot = scope === 'user'
@ -149,13 +161,18 @@ async function resolveExplicitAgents(
return results
}
function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] {
async function dedupeByRoot(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const seen = new Set<string>()
return candidates.filter(c => {
if (seen.has(c.rootDir)) return false
seen.add(c.rootDir)
return true
})
const deduped: AgentCandidate[] = []
for (const candidate of candidates) {
const canonicalRootDir = await canonicalizeExistingPath(candidate.rootDir)
if (seen.has(canonicalRootDir)) continue
seen.add(canonicalRootDir)
deduped.push(candidate)
}
return deduped
}
async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {

View file

@ -52,6 +52,13 @@ export interface DryRunResponse {
resolvedVersion: string | null
}
interface PublicErrorFields {
msg?: string
requestId?: string
}
type ErrorResponseKind = 'json' | 'download'
export class SkillHubClient {
constructor(
readonly registry: string,
@ -88,14 +95,8 @@ export class SkillHubClient {
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (response.status === 401 || response.status === 403) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 404) {
throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry })
}
if (!response.ok) {
throw new CliError(`download failed with status ${response.status}`, EXIT.generic, { registry: this.registry })
throw await this.createResponseError(response, 'download')
}
return response
}
@ -151,27 +152,67 @@ export class SkillHubClient {
}
private async handleJsonResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' })
}
if (response.status === 404) {
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
}
// 502/503 indicate network-level failures (connection refused, service unavailable)
if (response.status === 502 || response.status === 503) {
throw new CliError(`registry returned ${response.status}`, EXIT.network, { registry: this.registry })
}
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new CliError(`registry returned ${response.status}`, EXIT.generic, { registry: this.registry, detail: text })
throw await this.createResponseError(response, 'json')
}
const body = await response.json()
return body.data as T
}
private async createResponseError(response: Response, kind: ErrorResponseKind): Promise<CliError> {
const publicFields = await this.readPublicErrorFields(response)
const details: Record<string, unknown> = { registry: this.registry }
if (publicFields.requestId) {
details.requestId = publicFields.requestId
}
let fallback: string
let exitCode: number = EXIT.generic
if (response.status === 401) {
fallback = 'authentication failed'
exitCode = EXIT.auth
details.next = 'run `skillhub login`'
} else if (response.status === 403) {
fallback = 'access denied'
exitCode = EXIT.auth
} else if (response.status === 404) {
fallback = kind === 'download' ? 'skill or version not found' : 'resource not found'
} else if (response.status === 502 || response.status === 503) {
fallback = kind === 'download'
? `download failed with status ${response.status}`
: `registry returned ${response.status}`
exitCode = EXIT.network
} else {
fallback = kind === 'download'
? `download failed with status ${response.status}`
: `registry returned ${response.status}`
}
return new CliError(publicFields.msg ?? fallback, exitCode, details)
}
private async readPublicErrorFields(response: Response): Promise<PublicErrorFields> {
let body: unknown
try {
body = await response.json()
} catch {
return {}
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return {}
}
const record = body as Record<string, unknown>
const msg = typeof record.msg === 'string' ? record.msg.trim() : ''
const requestId = typeof record.requestId === 'string' ? record.requestId.trim() : ''
return {
...(msg ? { msg } : {}),
...(requestId ? { requestId } : {})
}
}
private headers(): HeadersInit {
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
}

View file

@ -28,14 +28,17 @@ export const commands = {
},
search: {
summary: 'Search published skills',
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--json]',
examples: ['skillhub search', 'skillhub search pdf']
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--token <token>] [--json]',
examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx']
},
install: {
summary: 'Install a skill locally',
usage: 'skillhub install <slug> [--scope <user|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
usage: 'skillhub install <coordinate> [--scope <user|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
examples: [
'skillhub install pdf-parser',
'skillhub install team/my-skill',
'skillhub install @team/my-skill',
'skillhub install team--my-skill',
'skillhub install pdf-parser --scope user',
'skillhub install pdf-parser --scope project --agent codex'
]
@ -47,8 +50,13 @@ export const commands = {
},
remove: {
summary: 'Remove local or remote skill',
usage: 'skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--json]',
examples: ['skillhub remove pdf-parser', 'skillhub remove pdf-parser --remote --hard']
usage: 'skillhub remove <coordinate> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--json]',
examples: [
'skillhub remove pdf-parser',
'skillhub remove team/my-skill',
'skillhub remove my-skill --namespace team',
'skillhub remove pdf-parser --remote --hard'
]
},
doctor: {
summary: 'Scan project and merge into local inventory (preserves entries outside scan scope)',

View file

@ -5,7 +5,7 @@ import { installSkill } from '../services/install-service'
import { resolveInstallTargets } from '../agents/resolver'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
import { resolveSkillName } from '../shared/skill-name-parser'
export interface InstallCommandOptions {
namespace?: string | undefined
@ -94,9 +94,7 @@ export async function installCommand(
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace)
const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets
const targets = await resolveTargets({

View file

@ -5,7 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service'
import { removeLocalSkill } from '../services/remove-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
import { hasExplicitNamespace, resolveSkillName } from '../shared/skill-name-parser'
export interface RemoveCommandOptions {
agent?: string[] | undefined
@ -30,9 +30,7 @@ export async function removeCommand(skillNameArg: string, options: RemoveCommand
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace)
if (options.remote) {
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
@ -62,8 +60,13 @@ export async function removeCommand(skillNameArg: string, options: RemoveCommand
}
// Local remove
const namespaceFilter = options.namespace !== undefined || hasExplicitNamespace(skillNameArg)
? namespace
: undefined
const result = await removeLocalSkill({
registry, slug,
registry,
namespace: namespaceFilter,
slug,
agents: options.agent,
all: options.all
})

View file

@ -1,3 +1,3 @@
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "0.1.7"
export const PKG_VERSION = "0.1.9"

View file

@ -223,15 +223,16 @@ cli
cli
.command('search [query]', 'Search published skills')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--limit <n>', 'Max results', { default: 20 })
.option('--json', 'Output JSON')
.action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => {
.action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => {
return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json))
})
cli
.command('install <slug>', 'Install a skill locally')
.option('--namespace <slug>', 'Namespace', { default: 'global' })
.command('install <coordinate>', 'Install a skill locally')
.option('--namespace <slug>', 'Namespace for a bare skill slug')
.option('--version <v>', 'Version')
.option('--scope <scope>', 'Install scope: user or project')
.option('--agent <profile>', 'Agent profile (repeatable)')
@ -255,17 +256,17 @@ cli
})
cli
.command('remove <slug>', 'Remove local or remote skill')
.command('remove <coordinate>', 'Remove local or remote skill')
.option('--agent <profile>', 'Filter by agent (repeatable)')
.option('--all', 'Remove all targets')
.option('--remote', 'Delete remote skill')
.option('--hard', 'Skip confirmation for remote delete')
.option('--namespace <slug>', 'Namespace for remote delete')
.option('--namespace <slug>', 'Namespace for local or remote delete')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((slug: string, options: RemoveCommandOptions & { agent?: string | string[] }) => {
return runCommand(() => removeCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
.action((coordinate: string, options: RemoveCommandOptions & { agent?: string | string[] }) => {
return runCommand(() => removeCommand(coordinate, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli

View file

@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise<boolean> {
}
}
export async function canonicalizeExistingPath(path: string): Promise<string> {
const { realpath } = await import('node:fs/promises')
try {
return await realpath(path)
} catch {
return path
}
}
export async function applyCredentialPermissions(path: string): Promise<void> {
if (process.platform === 'win32') return
const { chmod } = await import('node:fs/promises')

View file

@ -6,7 +6,7 @@ import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { extractZip } from '../platform/archive'
import { readBoundedResponseBody } from '../platform/download'
import { pathExists } from '../platform/paths'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from '../agents/types'
export interface InstallOptions {
@ -20,7 +20,40 @@ export interface InstallOptions {
home?: string | undefined
}
async function preflightInstallTargets(
targets: AgentCandidate[],
slug: string,
force: boolean
): Promise<Array<{ target: AgentCandidate; skillDir: string }>> {
const seenSkillDirs = new Set<string>()
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = []
for (const target of targets) {
const canonicalRootDir = await canonicalizeExistingPath(target.rootDir)
const canonicalSkillDir = join(canonicalRootDir, slug)
if (seenSkillDirs.has(canonicalSkillDir)) {
throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, {
path: canonicalSkillDir,
next: 'select only one target for this directory'
})
}
seenSkillDirs.add(canonicalSkillDir)
const skillDir = join(target.rootDir, slug)
if (await pathExists(skillDir) && !force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
preparedTargets.push({ target, skillDir })
}
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)
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
@ -29,16 +62,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const target of options.targets) {
const skillDir = join(target.rootDir, options.slug)
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 { target, skillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
let movedIntoPlace = false

View file

@ -15,6 +15,7 @@ function isPathUnder(child: string, parent: string): boolean {
export interface RemoveLocalOptions {
registry: string
namespace?: string | undefined
slug: string
agents?: string[] | undefined
all?: boolean | undefined
@ -29,7 +30,11 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise<Rem
const store = new InventoryStore(options.home)
const inventory = await store.read()
const items = inventory.items.filter(i => i.registry === options.registry && i.slug === options.slug)
const items = inventory.items.filter(item =>
item.registry === options.registry &&
item.slug === options.slug &&
(options.namespace === undefined || item.namespace === options.namespace)
)
if (items.length === 0) {
throw new CliError(`skill not found locally: ${options.slug}`, EXIT.generic, {
next: 'run `skillhub list` to see installed skills'

View file

@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string {
if (typeof cliError.details.path === 'string') {
lines.push(`Context: path ${cliError.details.path}`)
}
if (typeof cliError.details.requestId === 'string') {
lines.push(`Request ID: ${cliError.details.requestId}`)
}
if (typeof cliError.details.next === 'string') {
lines.push(`Next: ${cliError.details.next}`)
}

View file

@ -1,27 +1,91 @@
import { EXIT } from './constants'
import { CliError } from './errors'
export interface ParsedSkillName {
namespace: string
slug: string
}
export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
const separatorIndex = skillName.indexOf('--')
interface ParsedCoordinate {
namespace?: string
slug: string
}
if (separatorIndex <= 0) {
return {
namespace: defaultNamespace,
slug: separatorIndex === 0 ? skillName.slice(2) : skillName
}
function invalidCoordinate(skillName: string): CliError {
return new CliError(`invalid skill coordinate "${skillName}"`, EXIT.usage)
}
function parseSeparatedCoordinate(
skillName: string,
separatorIndex: number,
separatorLength: number,
namespaceStart = 0
): ParsedCoordinate {
const namespace = skillName.slice(namespaceStart, separatorIndex)
const slug = skillName.slice(separatorIndex + separatorLength)
if (!namespace || !slug || slug.includes('/')) {
throw invalidCoordinate(skillName)
}
if (separatorIndex === skillName.length - 2) {
return {
namespace: defaultNamespace,
slug: skillName.slice(0, -2)
return { namespace, slug }
}
function parseCoordinate(skillName: string): ParsedCoordinate {
if (!skillName) {
throw invalidCoordinate(skillName)
}
const slashIndex = skillName.indexOf('/')
if (skillName.startsWith('@')) {
if (slashIndex < 0) {
throw invalidCoordinate(skillName)
}
return parseSeparatedCoordinate(skillName, slashIndex, 1, 1)
}
const doubleDashIndex = skillName.indexOf('--')
if (slashIndex >= 0 && (doubleDashIndex < 0 || slashIndex < doubleDashIndex)) {
return parseSeparatedCoordinate(skillName, slashIndex, 1)
}
if (doubleDashIndex >= 0) {
return parseSeparatedCoordinate(skillName, doubleDashIndex, 2)
}
return { slug: skillName }
}
export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
const parsed = parseCoordinate(skillName)
return {
namespace: parsed.namespace ?? defaultNamespace,
slug: parsed.slug
}
}
/** Return whether a skill coordinate explicitly includes a namespace. */
export function hasExplicitNamespace(skillName: string): boolean {
return parseCoordinate(skillName).namespace !== undefined
}
/** Resolve a skill coordinate and an optional command-line namespace into one registry identity. */
export function resolveSkillName(skillName: string, explicitNamespace?: string): ParsedSkillName {
const parsed = parseCoordinate(skillName)
if (
parsed.namespace !== undefined &&
explicitNamespace !== undefined &&
parsed.namespace !== explicitNamespace
) {
throw new CliError(
`skill coordinate namespace "${parsed.namespace}" conflicts with --namespace "${explicitNamespace}"`,
EXIT.usage
)
}
return {
namespace: skillName.slice(0, separatorIndex),
slug: skillName.slice(separatorIndex + 2)
namespace: parsed.namespace ?? explicitNamespace ?? 'global',
slug: parsed.slug
}
}

View file

@ -22,19 +22,29 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
* 'forbidden' => 403 with a standard SkillHub error envelope
* 'forbidden_unstructured' => 403 with a non-JSON response body
* 'not_found' => 404 { code: 404, message: 'not found' }
* 'server_error' => 500 { code: 500, message: 'internal error' }
* 'network' => handler throws, causing fetch() to reject with a TypeError
*/
export type FailureMode = 'auth' | 'forbidden' | 'not_found' | 'server_error' | 'network'
export type FailureMode = 'auth' | 'forbidden' | 'forbidden_unstructured' | 'not_found' | 'server_error' | 'network'
function failureResponse(mode: FailureMode): Response {
switch (mode) {
case 'auth':
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
case 'forbidden':
return Response.json({ code: 403, message: 'forbidden' }, { status: 403 })
return Response.json({
code: 403,
msg: 'API token is missing required scope: skill:publish',
requestId: 'req-test-forbidden'
}, { status: 403 })
case 'forbidden_unstructured':
return new Response('<html>sensitive proxy denial</html>', {
status: 403,
headers: { 'Content-Type': 'text/html' }
})
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':

View file

@ -67,7 +67,7 @@ describe('cli error output', () => {
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('Error: missing required argument')
expect(result.stderr).toContain('Usage: skillhub install <slug>')
expect(result.stderr).toContain('Usage: skillhub install <coordinate>')
expect(result.stderr).toContain('Run "skillhub help install" for more information.')
})

View file

@ -5,8 +5,26 @@ describe('help command', () => {
test('prints detailed help for install', async () => {
const result = await runCli(['help', 'install'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Usage: skillhub install <slug>')
expect(result.stdout).toContain('Usage: skillhub install <coordinate>')
expect(result.stdout).toContain('--agent <profile>')
expect(result.stdout).toContain('@team/my-skill')
expect(result.stdout).toContain('team/my-skill')
expect(result.stdout).toContain('team--my-skill')
})
test('prints namespaced local remove contract in command help', async () => {
const result = await runCli(['help', 'remove'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Usage: skillhub remove <coordinate>')
expect(result.stdout).toContain('skillhub remove team/my-skill')
expect(result.stdout).toContain('skillhub remove my-skill --namespace team')
})
test('prints namespaced local remove contract in --help', async () => {
const result = await runCli(['remove', '--help'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('remove <coordinate>')
expect(result.stdout).toContain('Namespace for local or remote delete')
})
test('prints search help with optional query', async () => {

View file

@ -218,6 +218,65 @@ describe('install command — P1', () => {
expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
})
test('bad token stops on 401 without retrying resolve anonymously', async () => {
const env = await createTempHome()
const installDir = join(env.cwd, 'skills-no-anon-retry')
await mkdir(installDir, { recursive: true })
const resolveAuthHeaders: Array<string | null> = []
let downloadRequests = 0
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch) {
const auth = req.headers.get('authorization')
resolveAuthHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
namespace: resolveMatch[1],
slug: resolveMatch[2],
version: '1.0.0',
versionId: 1,
fingerprint: 'abc123',
downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download`
}
})
}
if (url.pathname.endsWith('/download')) {
downloadRequests += 1
return new Response(makeSkillZip() as BodyInit, {
status: 200,
headers: { 'Content-Type': 'application/zip' }
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('Error: authentication failed')
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
expect(result.stderr).toContain('Next:')
expect(resolveAuthHeaders).toEqual(['Bearer sk_bad'])
expect(downloadRequests).toBe(0)
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P1 — --namespace override
// -------------------------------------------------------------------------
@ -265,6 +324,83 @@ describe('install command — P1', () => {
expect(meta.version).toBe('2.0.0')
})
test.each([
'team/my-skill',
'@team/my-skill',
'team--my-skill'
])('%s resolves the namespaced registry path', async (coordinate) => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
skills: [{
namespace: 'team',
slug: 'my-skill',
version: '1.0.0',
zipBytes: makeSkillZip()
}]
})
const installDir = join(env.cwd, 'skills-coordinate')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', coordinate,
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout)).toMatchObject({
ok: true,
namespace: 'team',
slug: 'my-skill'
})
expect(registry.received.resolve).toMatchObject({
namespace: 'team',
slug: 'my-skill'
})
})
test('coordinate conflicting with --namespace fails before registry access', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
skills: [{
namespace: 'team',
slug: 'my-skill',
version: '1.0.0',
zipBytes: makeSkillZip()
}]
})
const installDir = join(env.cwd, 'skills-coordinate-conflict')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', '@team/my-skill',
'--namespace', 'other',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(5)
expect(JSON.parse(result.stderr)).toMatchObject({
ok: false,
exitCode: 5
})
expect(registry.received.resolve).toBeNull()
})
// -------------------------------------------------------------------------
// NOTE: multi-target interactive selection (TTY branch) is not tested here
// because Bun.spawn does not support PTY allocation. The interactive path

View file

@ -159,7 +159,7 @@ describe('publish --dry-run', () => {
expect(result.stderr).toContain('authentication')
})
test('--dry-run reports scope error on 403', async () => {
test('--dry-run surfaces the public message and request ID on a structured 403', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
await login(env, registry.url)
@ -172,5 +172,22 @@ describe('publish --dry-run', () => {
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('scope')
expect(result.stderr).toContain('Request ID: req-test-forbidden')
})
test('--dry-run uses a neutral fallback without leaking an unstructured 403 body', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden_unstructured' } })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('access denied')
expect(result.stderr).not.toContain('sensitive proxy denial')
})
})

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'node:fs/promises'
import { access, mkdir, writeFile } from 'node:fs/promises'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
@ -27,6 +27,15 @@ async function createInstallDir(path: string) {
await mkdir(path, { recursive: true })
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
/** Build a minimal inventory item with one target. */
function makeItem(opts: {
registry: string
@ -388,43 +397,131 @@ describe('remove command — local remove (P1)', () => {
expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code'])
})
// -------------------------------------------------------------------------
// P1: --agent + --namespace together filter precisely so a same-slug skill
// in a different namespace is not collateral damage.
// -------------------------------------------------------------------------
test('--agent + --namespace filters precisely; same slug under different namespace is untouched', async () => {
const namespacedRemoveCases: Array<[string, string[]]> = [
['namespace/slug coordinate', ['team/shared-skill']],
['@namespace/slug coordinate', ['@team/shared-skill']],
['namespace--slug coordinate', ['team--shared-skill']],
['--namespace flag', ['shared-skill', '--namespace', 'team']]
]
test.each(namespacedRemoveCases)('%s only removes the targeted same-slug namespace', async (_label, removeArgs) => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const aDir = `${rootDir}/codex/skills/dup-slug-A`
const bDir = `${rootDir}/codex/skills/dup-slug-B`
await createInstallDir(aDir)
await createInstallDir(bDir)
const globalDir = `${rootDir}/codex/skills/shared-skill`
const teamDir = `${rootDir}/claude-code/skills/shared-skill`
const otherDir = `${rootDir}/cursor/skills/shared-skill`
await createInstallDir(globalDir)
await createInstallDir(teamDir)
await createInstallDir(otherDir)
await seedInventory(env.home, [
{
registry: registry.url, namespace: 'team-a', slug: 'dup-slug-A', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: aDir, installedAt: '2026-04-20T00:00:00Z' }]
},
{
registry: registry.url, namespace: 'team-b', slug: 'dup-slug-B', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: bDir, installedAt: '2026-04-20T00:00:00Z' }]
}
makeItem({
registry: registry.url,
namespace: 'global',
slug: 'shared-skill',
agent: 'codex',
rootDir: `${rootDir}/codex`,
installDir: globalDir
}),
makeItem({
registry: registry.url,
namespace: 'team',
slug: 'shared-skill',
agent: 'claude-code',
rootDir: `${rootDir}/claude-code`,
installDir: teamDir
}),
makeItem({
registry: registry.url,
namespace: 'other',
slug: 'shared-skill',
agent: 'cursor',
rootDir: `${rootDir}/cursor`,
installDir: otherDir
})
])
// Remove dup-slug-A only — dup-slug-B should survive even though both
// share the codex agent.
const result = await runCli(
['remove', 'dup-slug-A', '--agent', 'codex', '--registry', registry.url],
['remove', ...removeArgs, '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed.removed).toHaveLength(1)
expect(parsed.removed[0]).toMatchObject({ namespace: 'team', agent: 'claude-code' })
expect(await pathExists(globalDir)).toBe(true)
expect(await pathExists(teamDir)).toBe(false)
expect(await pathExists(otherDir)).toBe(true)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: Array<{ slug: string }>
items: Array<{ namespace: string; slug: string; targets: Array<{ installDir: string }> }>
}
const slugs = inv.items.map(i => i.slug).sort()
expect(slugs).toEqual(['dup-slug-B'])
expect(inv.items.map(item => item.namespace).sort()).toEqual(['global', 'other'])
expect(inv.items.every(item => item.slug === 'shared-skill')).toBe(true)
expect(inv.items.map(item => item.targets[0]?.installDir).sort()).toEqual([globalDir, otherDir].sort())
})
test('bare slug retains cross-namespace local removal compatibility', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const globalDir = `${rootDir}/codex/skills/shared-skill`
const teamDir = `${rootDir}/claude-code/skills/shared-skill`
const otherDir = `${rootDir}/cursor/skills/shared-skill`
await createInstallDir(globalDir)
await createInstallDir(teamDir)
await createInstallDir(otherDir)
await seedInventory(env.home, [
makeItem({
registry: registry.url,
namespace: 'global',
slug: 'shared-skill',
agent: 'codex',
rootDir: `${rootDir}/codex`,
installDir: globalDir
}),
makeItem({
registry: registry.url,
namespace: 'team',
slug: 'shared-skill',
agent: 'claude-code',
rootDir: `${rootDir}/claude-code`,
installDir: teamDir
}),
makeItem({
registry: registry.url,
namespace: 'other',
slug: 'shared-skill',
agent: 'cursor',
rootDir: `${rootDir}/cursor`,
installDir: otherDir
})
])
const result = await runCli(
['remove', 'shared-skill', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed.removed.map((item: { namespace: string }) => item.namespace).sort()).toEqual([
'global',
'other',
'team'
])
expect(await pathExists(globalDir)).toBe(false)
expect(await pathExists(teamDir)).toBe(false)
expect(await pathExists(otherDir)).toBe(false)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: object[]
}
expect(inv.items).toEqual([])
})
})

View file

@ -10,6 +10,109 @@ afterEach(() => {
})
describe('search command', () => {
test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => {
let capturedAuth = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedAuth = req.headers.get('authorization') ?? ''
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const result = await runCli(
['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'],
{ SKILLHUB_TOKEN: 'sk_bad' }
)
expect(result.exitCode).toBe(0)
expect(capturedAuth).toBe('Bearer sk_ok')
expect(result.stdout).toContain('global/pdf-parser')
} finally {
server.stop()
}
})
test('bad --token fails with auth output and does not retry anonymously', async () => {
const authHeaders: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
const auth = req.headers.get('authorization')
authHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('Error: authentication failed')
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
expect(result.stderr).toContain('Next:')
expect(authHeaders).toEqual(['Bearer sk_bad'])
} finally {
server.stop()
}
})
test('bad --token returns structured json auth error', async () => {
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json'])
expect(result.exitCode).toBe(2)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.message).toBe('authentication failed')
expect(parsed.exitCode).toBe(2)
expect(parsed.details.registry).toBe(registryUrl)
expect(typeof parsed.details.next).toBe('string')
expect(parsed.details.next).toContain('skillhub login')
} finally {
server.stop()
}
})
test('prints compact search table', async () => {
registry = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }]

View file

@ -1,18 +1,30 @@
import { describe, expect, mock, test } from 'bun:test'
import { afterEach, describe, expect, mock, test } from 'bun:test'
import type { AgentCandidate } from '../../../src/agents/types'
interface PromptChoice {
value: AgentCandidate
}
interface PromptOptions {
choices?: PromptChoice[]
onRender?: (this: { cursor?: number }) => void
format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[]
}
const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? []
let selectPromptTargets = defaultSelectedTargets
mock.module('prompts', () => ({
default: (options: PromptOptions) => {
options.onRender?.call({ cursor: 1 })
return { selected: options.format?.([]) ?? [] }
return { selected: selectPromptTargets(options) }
}
}))
afterEach(() => {
selectPromptTargets = defaultSelectedTargets
})
const { resolveInstallTargets } = await import('../../../src/agents/resolver')
describe('resolveInstallTargets interactive prompt', () => {
@ -33,4 +45,32 @@ describe('resolveInstallTargets interactive prompt', () => {
expect(targets).toEqual([highlighted])
})
test('allows selecting generic alongside detected user targets', async () => {
selectPromptTargets = options => options.choices?.map(choice => choice.value) ?? []
const codex: AgentCandidate = {
agent: 'codex',
rootDir: '/home/u/.codex/skills',
scope: 'user',
source: 'detected'
}
const generic: AgentCandidate = {
agent: 'generic',
rootDir: '/home/u/.agents/skills',
scope: 'user',
source: 'fallback'
}
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex, generic])
})
})

View file

@ -1,5 +1,9 @@
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { resolveInstallTargets } from '../../../src/agents/resolver'
import type { AgentCandidate } from '../../../src/agents/types'
describe('resolveInstallTargets', () => {
test('rejects dir and agent together before filesystem writes', async () => {
@ -216,4 +220,36 @@ describe('resolveInstallTargets', () => {
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
expect(targets[0]!.scope).toBe('user')
})
test('deduplicates a symlinked detected target and the generic user target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-'))
const genericRoot = join(home, '.agents', 'skills')
const codexRoot = join(home, '.codex', 'skills')
const codex: AgentCandidate = {
agent: 'codex',
rootDir: codexRoot,
scope: 'user',
source: 'detected'
}
try {
await mkdir(genericRoot, { recursive: true })
await mkdir(join(home, '.codex'), { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
const targets = await resolveInstallTargets({
cwd: '/repo',
home,
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

View file

@ -36,13 +36,33 @@ describe('SkillHubClient', () => {
await err.toHaveProperty('exitCode', EXIT.auth)
})
test('download() throws auth error on 403', async () => {
test('download() preserves the server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token is missing required scope: skill:read',
requestId: 'req-download'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'API token is missing required scope: skill:read',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-download'
}
})
})
test('download() uses a neutral access error for an unstructured 403', async () => {
const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.download('ns', 'slug')).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('message', 'authentication failed')
await err.toHaveProperty('exitCode', EXIT.auth)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('download() throws not-found error on 404', async () => {
@ -72,6 +92,17 @@ describe('SkillHubClient', () => {
await err.toHaveProperty('exitCode', EXIT.generic)
})
test('download() retains its fallback while classifying 502 as a network error', async () => {
const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'download failed with status 502',
exitCode: EXIT.network,
details: { registry: 'http://registry.test' }
})
})
test('download() throws network error on fetch failure', async () => {
const fetchImpl = (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
@ -159,20 +190,229 @@ describe('SkillHubClient', () => {
// --- handleJsonResponse() non-2xx classification ---
test('whoami() throws generic error on 500', async () => {
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {
const fetchImpl = (async () => Response.json({
code: 401,
msg: 'token has been revoked',
requestId: 'req-401',
detail: 'internal token state',
stack: 'internal stack trace'
}, { status: 401 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.whoami()).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('exitCode', EXIT.generic)
const error = await client.whoami().catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('token has been revoked')
expect((error as CliError).exitCode).toBe(EXIT.auth)
expect((error as CliError).details).toEqual({
registry: 'http://registry.test',
requestId: 'req-401',
next: 'run `skillhub login`'
})
})
test('search() throws network error on 502', async () => {
test('search() preserves a public 403 message and request ID', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'token has been revoked',
requestId: 'req-403'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
try {
await client.search('test', 20)
throw new Error('expected search to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('token has been revoked')
expect((error as CliError).exitCode).toBe(EXIT.auth)
expect((error as CliError).details).toEqual({
registry: 'http://registry.test',
requestId: 'req-403'
})
}
})
test('search() uses a neutral 403 fallback when msg is absent', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
requestId: 'req-fallback'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
try {
await client.search('test', 20)
throw new Error('expected search to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('access denied')
expect((error as CliError).exitCode).toBe(EXIT.auth)
expect((error as CliError).details).toEqual({
registry: 'http://registry.test',
requestId: 'req-fallback'
})
}
})
test('search() uses a neutral 403 fallback for a non-JSON body', async () => {
const fetchImpl = (async () => new Response('<html>forbidden</html>', {
status: 403,
headers: { 'Content-Type': 'text/html' }
})) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
try {
await client.search('test', 20)
throw new Error('expected search to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('access denied')
expect((error as CliError).exitCode).toBe(EXIT.auth)
expect((error as CliError).details).toEqual({ registry: 'http://registry.test' })
}
})
test('whoami() preserves a structured 404 message and request ID', async () => {
const fetchImpl = (async () => Response.json({
code: 404,
msg: 'namespace not found',
requestId: 'req-404'
}, { status: 404 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
try {
await client.whoami()
throw new Error('expected whoami to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('namespace not found')
expect((error as CliError).exitCode).toBe(EXIT.generic)
expect((error as CliError).details).toEqual({
registry: 'http://registry.test',
requestId: 'req-404'
})
}
})
test('whoami() uses the resource fallback on an unstructured 404', async () => {
const fetchImpl = (async () => new Response(null, { status: 404 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'resource not found',
exitCode: EXIT.generic,
details: { registry: 'http://registry.test' }
})
})
test('download() preserves a structured 403 message and request ID', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'namespace access denied',
requestId: 'req-download'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
try {
await client.download('team', 'private-skill')
throw new Error('expected download to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).message).toBe('namespace access denied')
expect((error as CliError).exitCode).toBe(EXIT.auth)
expect((error as CliError).details).toEqual({
registry: 'http://registry.test',
requestId: 'req-download'
})
}
})
test('whoami() surfaces server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token cannot access endpoint: /api/cli/v1/whoami',
requestId: 'req-610'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'API token cannot access endpoint: /api/cli/v1/whoami',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-610'
}
})
})
test('whoami() falls back to generic access denied when 403 body is invalid', async () => {
const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('whoami() preserves public fields on a structured 500', async () => {
const fetchImpl = (async () => Response.json({
code: 500,
msg: 'registry operation failed',
requestId: 'req-500',
detail: 'internal database error'
}, { status: 500 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'registry operation failed',
exitCode: EXIT.generic,
details: {
registry: 'http://registry.test',
requestId: 'req-500'
}
})
})
test('whoami() does not expose a raw non-JSON 500 body', async () => {
const fetchImpl = (async () => new Response('internal stack trace', { status: 500 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'registry returned 500',
exitCode: EXIT.generic,
details: { registry: 'http://registry.test' }
})
})
test('search() preserves public fields and network classification on a structured 502', async () => {
const fetchImpl = (async () => Response.json({
code: 502,
msg: 'registry upstream unavailable',
requestId: 'req-502'
}, { status: 502 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.search('test', 20)).rejects.toMatchObject({
message: 'registry upstream unavailable',
exitCode: EXIT.network,
details: {
registry: 'http://registry.test',
requestId: 'req-502'
}
})
})
test('search() uses the network fallback on an unstructured 502', async () => {
const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.search('test', 20)).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('exitCode', EXIT.network)
await expect(client.search('test', 20)).rejects.toMatchObject({
message: 'registry returned 502',
exitCode: EXIT.network,
details: { registry: 'http://registry.test' }
})
})
// --- deleteRemote() (P1) ---

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { CliError } from '../../../src/shared/errors'
import { EXIT } from '../../../src/shared/constants'
import {
computeStrictIsTTY,
installCommand,
@ -136,6 +137,63 @@ describe('installCommand dependency injection', () => {
return async () => ({ installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }] })
}
function fakeResolveInstallTargets(): NonNullable<InstallCommandDeps['resolveInstallTargets']> {
return async () => [{
agent: 'codex',
rootDir: '/home/u/.codex/skills',
scope: 'user',
source: 'explicit'
}] as AgentCandidate[]
}
test('passes a namespaced coordinate to installSkill', async () => {
let received: Parameters<NonNullable<InstallCommandDeps['installSkill']>>[0] | undefined
const deps: InstallCommandDeps = {
isTTY: () => false,
resolveInstallTargets: fakeResolveInstallTargets(),
installSkill: async (options) => {
received = options
return { installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/my-skill' }] }
}
}
await installCommand('@team/my-skill', {
registry: 'http://localhost',
token: 'sk'
}, deps)
expect(received).toMatchObject({
namespace: 'team',
slug: 'my-skill'
})
})
test('rejects a conflicting namespace before installing', async () => {
let installCalls = 0
let error: unknown
const deps: InstallCommandDeps = {
isTTY: () => false,
resolveInstallTargets: fakeResolveInstallTargets(),
installSkill: async () => {
installCalls += 1
return { installed: [] }
}
}
try {
await installCommand('@team/my-skill', {
namespace: 'other',
registry: 'http://localhost',
token: 'sk'
}, deps)
} catch (caught) {
error = caught
}
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).exitCode).toBe(EXIT.usage)
expect(installCalls).toBe(0)
})
test('passes prompted scope and strict isTTY into resolveInstallTargets', async () => {
const calls: { promptScope: number; resolverCalls: ResolveInstallTargetOptions[] } = {
promptScope: 0,

View file

@ -1,4 +1,4 @@
import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
@ -72,6 +72,62 @@ describe('installSkill', () => {
})).rejects.toThrow('skill already installed')
})
test('preflights all targets before writing when a later target is occupied', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-'))
const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-'))
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
await mkdir(secondSkillDir, { recursive: true })
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: false,
home
})).rejects.toThrow(`skill already installed at ${secondSkillDir}`)
expect(await exists(firstSkillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).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-'))
const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const genericRoot = join(targetParent, 'generic')
const codexRoot = join(targetParent, 'codex')
const skillDir = join(genericRoot, 'demo')
try {
await mkdir(genericRoot, { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' },
{ agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' }
],
force: false,
home
})).rejects.toThrow('multiple install targets resolve to')
expect(await exists(skillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
} finally {
await rm(home, { recursive: true, force: true })
await rm(targetParent, { recursive: true, force: true })
}
})
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-'))

View file

@ -15,7 +15,7 @@ async function exists(path: string): Promise<boolean> {
}
describe('removeLocalSkill', () => {
test('removes all current-registry installs with the same slug across namespaces', async () => {
test('bare slug removes all current-registry installs with the same slug across namespaces', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-home-'))
const root = await mkdtemp(join(tmpdir(), 'skillhub-remove-root-'))
const globalDir = join(root, 'codex', 'demo')
@ -51,6 +51,47 @@ describe('removeLocalSkill', () => {
expect((await store.read()).items).toEqual([])
})
test('namespace filter removes only the matching same-slug install', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-home-'))
const root = await mkdtemp(join(tmpdir(), 'skillhub-remove-root-'))
const globalDir = join(root, 'codex', 'demo')
const teamDir = join(root, 'claude', 'demo')
await mkdir(globalDir, { recursive: true })
await mkdir(teamDir, { 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: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }]
},
{
registry: 'https://skill.xfyun.cn',
namespace: 'team',
slug: 'demo',
version: '1.0.0',
targets: [{ agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }]
}
]
})
const result = await removeLocalSkill({
registry: 'https://skill.xfyun.cn',
namespace: 'team',
slug: 'demo',
home
})
expect(result.removed.map(item => item.namespace)).toEqual(['team'])
expect(await exists(globalDir)).toBe(true)
expect(await exists(teamDir)).toBe(false)
expect((await store.read()).items.map(item => item.namespace)).toEqual(['global'])
})
test('throws on path traversal in installDir', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-traversal-'))

View file

@ -16,14 +16,28 @@ describe('renderError', () => {
test('renders human error without stack trace', () => {
const error = new CliError('registry unreachable', 3, {
registry: 'https://registry.example.com',
requestId: 'req-610',
next: 'check network or pass --registry'
})
expect(renderError(error, false)).toBe([
'Error: registry unreachable',
'Context: registry https://registry.example.com',
'Request ID: req-610',
'Next: check network or pass --registry'
].join('\n'))
})
test('renders a server request ID for human-readable errors', () => {
const error = new CliError('token has been revoked', 2, {
registry: 'https://registry.example.com',
requestId: 'req-403'
})
expect(renderError(error, false)).toBe([
'Error: token has been revoked',
'Context: registry https://registry.example.com',
'Request ID: req-403'
].join('\n'))
})
})
describe('printResult', () => {

View file

@ -1,90 +1,85 @@
import { describe, test, expect } from 'bun:test'
import { parseSkillName } from '../../../src/shared/skill-name-parser'
import { describe, expect, test } from 'bun:test'
import { parseSkillName, resolveSkillName } from '../../../src/shared/skill-name-parser'
import { EXIT } from '../../../src/shared/constants'
import { CliError } from '../../../src/shared/errors'
function expectUsageError(callback: () => unknown): void {
let error: unknown
try {
callback()
} catch (caught) {
error = caught
}
expect(error).toBeInstanceOf(CliError)
expect((error as CliError).exitCode).toBe(EXIT.usage)
}
describe('parseSkillName', () => {
describe('with namespace--slug format', () => {
test('should parse namespace and slug separated by double dash', () => {
const result = parseSkillName('astroclaw--api-gateway')
expect(result).toEqual({
namespace: 'astroclaw',
slug: 'api-gateway'
})
})
test.each([
['my-skill', { namespace: 'global', slug: 'my-skill' }],
['team/my-skill', { namespace: 'team', slug: 'my-skill' }],
['@team/my-skill', { namespace: 'team', slug: 'my-skill' }],
['team--my-skill', { namespace: 'team', slug: 'my-skill' }]
])('parses %s', (skillName, expected) => {
expect(parseSkillName(skillName)).toEqual(expected)
})
test('should handle namespace and slug with single dashes', () => {
const result = parseSkillName('my-org--my-skill-name')
expect(result).toEqual({
namespace: 'my-org',
slug: 'my-skill-name'
})
})
test('should handle multiple double dashes by using first as separator', () => {
const result = parseSkillName('namespace--slug--with--dashes')
expect(result).toEqual({
namespace: 'namespace',
slug: 'slug--with--dashes'
})
test('preserves double dashes after the coordinate separator', () => {
expect(parseSkillName('namespace--slug--with--dashes')).toEqual({
namespace: 'namespace',
slug: 'slug--with--dashes'
})
})
describe('with slug only format', () => {
test('should use default namespace when no separator present', () => {
const result = parseSkillName('api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should use custom default namespace when provided', () => {
const result = parseSkillName('api-gateway', 'myorg')
expect(result).toEqual({
namespace: 'myorg',
slug: 'api-gateway'
})
})
test('should handle slug with single dashes', () => {
const result = parseSkillName('my-skill-name')
expect(result).toEqual({
namespace: 'global',
slug: 'my-skill-name'
})
test('preserves the custom default namespace for a bare slug', () => {
expect(parseSkillName('api-gateway', 'myorg')).toEqual({
namespace: 'myorg',
slug: 'api-gateway'
})
})
describe('edge cases', () => {
test('should handle separator at start', () => {
const result = parseSkillName('--api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should handle separator at end', () => {
const result = parseSkillName('astroclaw--')
expect(result).toEqual({
namespace: 'global',
slug: 'astroclaw'
})
})
test('should handle empty string', () => {
const result = parseSkillName('')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
test('should handle just separator', () => {
const result = parseSkillName('--')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
test.each([
'',
'@team',
'team/',
'/my-skill',
'--my-skill',
'team--',
'team/my-skill/extra',
'@team/my-skill/extra',
'team--my-skill/extra'
])('rejects malformed coordinate %p', (skillName) => {
expectUsageError(() => parseSkillName(skillName))
})
})
describe('resolveSkillName', () => {
test('uses global for a bare slug without an explicit namespace', () => {
expect(resolveSkillName('my-skill')).toEqual({
namespace: 'global',
slug: 'my-skill'
})
})
test('uses an explicit namespace for a bare slug', () => {
expect(resolveSkillName('my-skill', 'team')).toEqual({
namespace: 'team',
slug: 'my-skill'
})
})
test.each([
'team/my-skill',
'@team/my-skill',
'team--my-skill'
])('accepts matching --namespace for %s', (skillName) => {
expect(resolveSkillName(skillName, 'team')).toEqual({
namespace: 'team',
slug: 'my-skill'
})
})
test('rejects a coordinate that conflicts with --namespace', () => {
expectUsageError(() => resolveSkillName('@team/my-skill', 'other'))
})
})

View file

@ -116,6 +116,7 @@ services:
- "${WEB_PORT:-80}:80"
environment:
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
SKILLHUB_TRUST_FORWARDED_PROTO: ${SKILLHUB_TRUST_FORWARDED_PROTO:-false}
SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false}

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式
@ -192,6 +194,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | LLM 模型名称 | 否 |
### 存储配置

View file

@ -28,6 +28,12 @@ spec:
name: skillhub-secret
key: skill-scanner-llm-api-key
optional: true
- name: SKILL_SCANNER_LLM_BASE_URL
valueFrom:
secretKeyRef:
name: skillhub-secret
key: skill-scanner-llm-base-url
optional: true
- name: SKILL_SCANNER_LLM_MODEL
valueFrom:
secretKeyRef:

View file

@ -24,6 +24,7 @@ stringData:
# LLM 配置(可选,用于技能扫描)
skill-scanner-llm-api-key: ""
skill-scanner-llm-base-url: ""
skill-scanner-llm-model: ""
# S3 存储配置(可选,使用 S3/OSS 时配置)

View file

@ -377,7 +377,9 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session若无 Session公共读接口按匿名访问`whoami` 返回 401
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 拒绝原因API Token 缺少作用域或不能访问某个接口时403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
@ -484,23 +486,20 @@ Session 中存储以下字段:
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"userId": "usr_42",
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
"oauthProvider": "local",
"canChangePassword": true,
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```
前端权限判定基于 `platformRoles` + `namespaces[].role`后端通过 `role_permission` 表查询权限码。
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`后端通过 `role_permission` 表查询权限码。
统一约束:
- `/api/v1/auth/me``/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
@ -604,8 +603,8 @@ window.location.href = '/oauth2/authorization/github'
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY成员空间+ PRIVATEowner/admin` | `SearchVisibilityScope` + 搜索索引状态 |
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须`PUBLISHED` | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
### 10.2 Authenticated API
@ -623,10 +622,15 @@ window.location.href = '/oauth2/authorization/github'
### 10.3 CLI API
| 接口 | 所需凭证 | 额外判定 |
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 |
| 接口 | 凭证规则 | 授权与错误语义 |
|------|---------|---------------|
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401坏 Bearer 即使存在 Session 也返回 401 |
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill有效 Bearer 覆盖 Session坏 Bearer 返回 401不得降级 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session确保请求使用 token 的用户、角色与 scopeBearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`
### 10.4 Admin API
@ -654,6 +658,6 @@ window.location.href = '/oauth2/authorization/github'
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `GET /api/v1/search` | 可选(匿名限 PUBLIC | `SearchVisibilityScope` |
| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过namespace 由 canonical slug 解析) |

View file

@ -319,7 +319,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
|------|------|------|
| GET | `/api/v1/admin/users` | 用户列表 |
| GET | `/api/v1/admin/users/{id}` | 用户详情 |
| PUT | `/api/v1/admin/users/{id}/roles` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN |
| PUT | `/api/v1/admin/users/{id}/role` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN,也不可修改已有 SUPER_ADMIN 的角色状态 |
| POST | `/api/v1/admin/users/{id}/approve` | 审批待准入用户 |
| POST | `/api/v1/admin/users/{id}/disable` | 封禁用户 |
| POST | `/api/v1/admin/users/{id}/enable` | 解封用户 |

View file

@ -194,6 +194,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release`
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
- `SKILLHUB_TRUST_FORWARDED_PROTO` 默认保持 `false`。只有 Web 容器仅能经由可信
TLS 终止代理访问,且该代理会覆盖客户端传入的 `X-Forwarded-Proto` 时才设为
`true`;否则客户端可伪造协议并影响 OAuth 回调、重定向和安全 Cookie 判断
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`

View file

@ -0,0 +1,305 @@
openapi: 3.0.3
info:
title: SkillHub CLI Authentication API
version: 1.0.0
description: >-
Authentication contract for CLI identity and public skill reads. A valid
Bearer credential overrides a Web Session identity. Once the Bearer scheme
is used, the credential must be valid: empty, malformed, unknown, expired,
or revoked Bearer credentials return HTTP 401 and never fall back to the
Session or anonymous access. An absent Authorization header or an
unsupported scheme such as Basic preserves a valid Web Session. Without a
Session, public reads use anonymous visibility and whoami returns HTTP 401.
servers:
- url: /
tags:
- name: CLI Authentication
- name: CLI Skills
paths:
/api/cli/v1/auth/whoami:
get:
tags: [CLI Authentication]
summary: Return the current CLI identity
operationId: cliWhoAmI
description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists.
security:
- bearerAuth: []
- sessionAuth: []
responses:
'200':
description: Authenticated CLI identity
content:
application/json:
schema:
$ref: '#/components/schemas/CliWhoAmIEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/search:
get:
tags: [CLI Skills]
summary: Search CLI-installable skills
operationId: cliSearchSkills
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- name: q
in: query
required: false
schema: {type: string}
example: pdf
description: Optional search text.
- name: limit
in: query
required: false
schema: {type: integer, format: int32, default: 20}
example: 20
description: Maximum number of results.
responses:
'200':
description: Search result
content:
application/json:
schema:
$ref: '#/components/schemas/CliSearchEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/{namespace}/{slug}/resolve:
get:
tags: [CLI Skills]
summary: Resolve a skill version
operationId: cliResolveSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- name: version
in: query
required: false
schema: {type: string}
example: 1.0.0
description: Optional exact version; omitted resolves latest.
responses:
'200':
description: Resolved version
content:
application/json:
schema:
$ref: '#/components/schemas/CliResolveEnvelope'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/cli/v1/skills/{namespace}/{slug}/download:
get:
tags: [CLI Skills]
summary: Download the latest installable skill version
operationId: cliDownloadLatestSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download:
get:
tags: [CLI Skills]
summary: Download an exact installable skill version
operationId: cliDownloadSkillVersion
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- $ref: '#/components/parameters/Version'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: SkillHub API token
description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback.
sessionAuth:
type: apiKey
in: cookie
name: SESSION
description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token.
parameters:
Namespace:
name: namespace
in: path
required: true
schema: {type: string}
example: global
description: Namespace slug.
Slug:
name: slug
in: path
required: true
schema: {type: string}
example: pdf-parser
description: Skill slug.
Version:
name: version
in: path
required: true
schema: {type: string}
example: 1.0.0
description: Exact semantic version.
responses:
Download:
description: ZIP package stream
headers:
Content-Disposition:
schema: {type: string}
description: Attachment filename.
content:
application/zip:
schema: {type: string, format: binary}
DownloadRedirect:
description: Redirect to a presigned object-storage URL
headers:
Location:
schema: {type: string, format: uri}
BadRequest:
description: Namespace, skill, or version cannot be resolved.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
Unauthorized:
description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 401
msg: Authentication required
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
Forbidden:
description: Credential is valid but token scope or resource permission is insufficient.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 403
msg: Forbidden
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
StorageUnavailable:
description: Object storage is unavailable.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
Envelope:
type: object
required: [code, msg, data, timestamp, requestId]
properties:
code: {type: integer, format: int32}
msg: {type: string}
data: {type: object, nullable: true}
timestamp: {type: string, format: date-time}
requestId: {type: string, example: req-123}
ErrorEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: {type: object, nullable: true, example: null}
CliWhoAmIEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliWhoAmI'
CliWhoAmI:
type: object
required: [handle, displayName, email]
properties:
handle: {type: string, example: user-123}
displayName: {type: string, example: CLI User}
email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.}
CliSearchEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliSearchResult'
CliSearchResult:
type: object
required: [items, total, limit]
properties:
items:
type: array
items: {$ref: '#/components/schemas/CliSearchItem'}
total: {type: integer, format: int64, example: 1}
limit: {type: integer, format: int32, example: 20}
CliSearchItem:
type: object
required: [namespace, slug, latestVersion]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
latestVersion: {type: string, example: 1.2.0}
summary: {type: string, nullable: true, example: Parse PDF files}
CliResolveEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliResolveResult'
CliResolveResult:
type: object
required: [namespace, slug, version, versionId, fingerprint, downloadUrl]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
version: {type: string, example: 1.2.0}
versionId: {type: integer, format: int64, example: 42}
fingerprint: {type: string, example: 'sha256:abc123'}
downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download}

View file

@ -0,0 +1,278 @@
# Hermes Agent Integration Guide
This guide explains how to install skills from SkillHub into [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent), then discover, load, update, and remove those skills in Hermes.
“Hermes” in this guide means `NousResearch/hermes-agent`; it does not cover other projects with the same name.
## Validated scope
| Component | Validated version | Notes |
|-----------|-------------------|-------|
| SkillHub Server | `v0.2.13` | Public or self-hosted registry |
| SkillHub CLI | `0.1.8` | npm package `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | Upstream tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
Validation date: 2026-07-17.
Hermes 0.18.2 uses an [Agent Skills](https://agentskills.io/)-compatible `SKILL.md` format and recursively scans `$HERMES_HOME/skills/`. SkillHub CLI can extract a complete skill package into any explicit `--dir` target. The current integration therefore needs no format conversion, Hermes-specific CLI profile, or server adapter:
```text
SkillHub registry
-> skillhub install --dir <Hermes skills directory>
-> <Hermes skills directory>/<skill-slug>/SKILL.md
-> Hermes discovers and loads the skill on demand
```
> Hermes 0.18.2 has no native SkillHub registry source. This guide uses SkillHub CLI for search, download, and local installation, while Hermes handles discovery and execution.
## Prerequisites
1. Install and initialize Hermes Agent.
2. Install SkillHub CLI:
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. Ensure the skill package has a valid root `SKILL.md` with at least `name` and `description` frontmatter.
The examples below use Bash/zsh. On Windows, use the same directory structure, replace the default Hermes home with `$HOME\.hermes`, and set variables using PowerShell syntax.
## Quick start
### 1. Configure the SkillHub registry
Set the public or self-hosted SkillHub URL:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
You can skip login for public skills that allow anonymous downloads. For team namespaces, restricted skills, or private deployments, save an API token first:
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
Use placeholder tokens in examples. Never write a real token into `SKILL.md`, scripts, or version control.
### 2. Search for a skill
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
Record the namespace, slug, and required version. The following examples use `my-team/my-skill`:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. Install into the primary Hermes skills directory
Set the home of the active Hermes profile. The default profile normally uses `~/.hermes`; if you use a custom `HERMES_HOME` or a named profile, point it at the actual profile directory:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
Install the skill:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI preserves `SKILL.md`, `references/`, `scripts/`, `templates/`, `assets/`, and other package files. It also writes `.skillhub/metadata.json` to record the installation source. The resulting layout looks like this:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # optional
├── scripts/ # optional
└── .skillhub/
└── metadata.json
```
Separating target directories by namespace reduces filesystem collisions between skills with the same slug. Hermes recursively scans these levels.
### 4. Verify and load the skill in Hermes
First, confirm that Hermes discovers the skill:
```bash
hermes skills list --source local --enabled-only
```
Then start Hermes and invoke the slash command normalized from the skill `name`:
```bash
hermes
```
```text
/my-skill
```
You can also ask Hermes in natural language to use the skill. Hermes lists the raw `SKILL.md` frontmatter `name`, but its slash command lowercases that name, replaces spaces and underscores with hyphens, removes other characters outside `a-z0-9-`, and collapses repeated hyphens. For example, `PDF_Tools` becomes `/pdf-tools`. The command may therefore differ from the SkillHub slug.
If a running session does not immediately show a new skill, run `/reload-skills` or restart the session.
## Update a skill
SkillHub CLI 0.1.8 overwrites a local skill by repeating the install command with `--force`. Omitting `--version` resolves the latest published version; you can also pin one explicitly:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# Pinned version example
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
Review the new version before overwriting because `--force` replaces the existing skill directory. Afterward, run:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` updates SkillHub CLI itself; it does not update installed skills. Refresh an installed skill with `skillhub install ... --force`.
## Remove a skill
First, list every installation from the same registry and confirm that there are no other same-slug skills you need to keep:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
Then remove the local installation:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI deletes both the skill directory and the local inventory record. Local `remove` in this version matches only registry and slug; it does not filter by namespace or directory. Every same-slug target from that registry, across all namespaces and installation directories, is removed. If the unfiltered `skillhub list` shows a match you need to keep, do not run the command; namespace- or directory-scoped removal requires a future CLI capability.
After removal, run `/reload-skills`, restart the Hermes session, or confirm that the skill is gone with:
```bash
hermes skills list --source local --enabled-only
```
## Optional: use a shared external skills directory
When several agents share `~/.agents/skills`, install SkillHub skills into that shared tree instead of the primary Hermes directory:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
Merge the shared root into `$HERMES_HOME/config.yaml` without replacing existing `skills` settings:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes lists and loads external skills alongside local skills. Do not rely on local shadowing: Hermes 0.18.2 refuses ambiguous `skill_view` matches across the local skills directory and `external_dirs`. Rename or remove a colliding copy instead.
> `external_dirs` is not a read-only boundary. If the Hermes process can write to an external directory, Hermes skill-management tools can modify its files. Use filesystem permissions or an isolated Hermes profile when shared skills must remain read-only.
## Compatibility and security boundaries
- **Format compatibility is not complete runtime compatibility.** Hermes can read `SKILL.md` and supporting files, but agent-specific tools, MCP servers, commands, environment variables, and platform capabilities referenced by a skill still need individual verification.
- **Hermes treats this path as local.** A skill copied by SkillHub CLI does not run through the Hermes Skills Hub community-install scanner. Review the SkillHub security report and the skill contents before installation, and use Hermes terminal isolation where appropriate.
- **Keep multi-file packages intact.** Do not replace SkillHub CLI with the Hermes 0.18.2 direct-URL source for multi-file skills. That release guarantees a single `SKILL.md` for URL installs, whereas SkillHub CLI extracts the complete package.
- **Avoid name collisions.** Namespace-separated filesystem paths do not resolve slash-command collisions. Keep normalized command names unique within one Hermes profile. For example, `PDF Tools` and `pdf_tools` both become `/pdf-tools`.
- **Protect credentials.** A registry token is only for SkillHub access and does not belong in a skill package. Skills that need runtime secrets should use Hermes environment-variable and security settings.
## Troubleshooting
### The new skill is missing from the Hermes list
Check these items in order:
1. The current session uses the same `HERMES_HOME` used during installation.
2. The final path contains `<skill-directory>/SKILL.md`.
3. `SKILL.md` contains valid `name` and `description` fields.
4. `platforms` or other frontmatter does not exclude the current operating system.
5. The skill appears after `/reload-skills` or in a new session.
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### Installation reports `skill already installed`
Existing directories are not overwritten by default. Review the target version, then add `--force`:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### The CLI reports `registry unreachable` or a download failure
- Confirm that `SKILLHUB_REGISTRY` is the SkillHub root URL.
- Run `skillhub search` against the same registry to distinguish registry reachability from a download failure.
- Check proxy, DNS, certificate, and self-hosted service status.
- Retry a transient network error only after confirming the service is healthy; do not bypass certificate failures by disabling TLS verification.
### The skill is listed but fails during execution
Check tool names, shell commands, script runtimes, packages, MCP servers, environment variables, and operating-system restrictions referenced by that skill. Those are skill-specific runtime compatibility concerns, not failures of `SKILL.md` discovery.
### Can `hermes skills install` consume a SkillHub coordinate directly?
Hermes 0.18.2 has no SkillHub registry source and cannot resolve a SkillHub namespace/slug directly. Use `skillhub install --dir ...` as shown in this guide. Native search, installation, updates, and security scanning inside Hermes would require a separately designed Hermes source adapter with its own protocol and acceptance scope.
## Regression checks after upgrades
After upgrading SkillHub CLI or Hermes, verify at least the following:
1. `skillhub install --dir` still creates `<slug>/SKILL.md` and preserves support files.
2. `hermes skills list --source local --enabled-only` discovers the skill.
3. `/skill-name` loads `SKILL.md` and exposes support-file paths; then read one referenced file with `skill_view(name, file_path)` or exercise the script/asset the skill actually uses.
4. `skillhub install --force` overwrites the skill while keeping a healthy inventory.
5. Hermes no longer discovers the skill after `skillhub remove`.
Upstream reference: [Hermes Skills System at v0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md).

278
docs/hermes-integration.md Normal file
View file

@ -0,0 +1,278 @@
# Hermes Agent 集成指南
本文档说明如何把 SkillHub 中的技能安装到 [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent),并在 Hermes 中发现、加载、更新和移除这些技能。
本文中的 “Hermes” 特指 `NousResearch/hermes-agent`,不适用于其他同名项目。
## 已验证范围
| 组件 | 已验证版本 | 说明 |
|------|------------|------|
| SkillHub Server | `v0.2.13` | 公开或自托管 registry |
| SkillHub CLI | `0.1.8` | npm 包 `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | 上游 tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
验证日期2026-07-17。
Hermes 0.18.2 使用兼容 [Agent Skills](https://agentskills.io/) 的 `SKILL.md` 格式,并递归扫描 `$HERMES_HOME/skills/`。SkillHub CLI 可以通过 `--dir` 把完整技能包解压到指定目录。因此当前兼容链路不需要格式转换、Hermes 专用 CLI profile 或服务端适配:
```text
SkillHub registry
-> skillhub install --dir <Hermes 技能目录>
-> <Hermes 技能目录>/<skill-slug>/SKILL.md
-> Hermes 发现并按需加载
```
> Hermes 0.18.2 没有原生 SkillHub registry source。本指南使用 SkillHub CLI 负责搜索、下载和本地安装Hermes 负责发现和执行技能。
## 前置条件
1. 已安装并初始化 Hermes Agent。
2. 已安装 SkillHub CLI
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. 技能包根目录包含有效的 `SKILL.md`,其中至少有 `name``description` frontmatter。
以下示例使用 Bash/zsh。Windows 用户可使用同一目录结构,将默认 Hermes 主目录替换为 `$HOME\.hermes`,并按 PowerShell 语法设置变量。
## 快速开始
### 1. 配置 SkillHub registry
设置公开或自托管 SkillHub 地址:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
公开且允许匿名下载的技能可以跳过登录。访问团队命名空间、受限技能或私有部署时,先保存 API Token
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
请使用占位 Token 演示,不要把真实 Token 写入 `SKILL.md`、脚本或版本库。
### 2. 搜索技能
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
记录结果中的 namespace、slug 和所需版本。下面以 `my-team/my-skill` 为例:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. 安装到 Hermes 主技能目录
设置当前 Hermes profile 的主目录。默认 profile 通常是 `~/.hermes`;如果使用自定义 `HERMES_HOME` 或命名 profile请指向实际 profile 目录:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
安装技能:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会保留技能包中的 `SKILL.md``references/``scripts/``templates/``assets/` 等文件,并额外写入 `.skillhub/metadata.json` 记录安装来源。目录结构类似:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # 可选
├── scripts/ # 可选
└── .skillhub/
└── metadata.json
```
按 namespace 分目录可以减少不同命名空间中同 slug 技能的文件路径冲突。Hermes 会递归扫描这些层级。
### 4. 在 Hermes 中验证和加载
先确认 Hermes 发现了技能:
```bash
hermes skills list --source local --enabled-only
```
然后启动 Hermes在会话中使用由技能 `name` 规范化得到的斜杠命令:
```bash
hermes
```
```text
/my-skill
```
也可以在自然语言请求中明确要求 Hermes 使用该技能。Hermes 列表显示 `SKILL.md` frontmatter 中的原始 `name`,斜杠命令会把它转为小写、把空格和下划线替换为连字符、移除其他非 `a-z0-9-` 字符,并合并重复连字符。例如 `PDF_Tools` 对应 `/pdf-tools`。该命令不一定与 SkillHub slug 相同。
已经运行的会话未立即显示新技能时,执行 `/reload-skills` 或重新启动会话。
## 更新技能
SkillHub CLI 0.1.8 使用同一安装命令加 `--force` 覆盖本地技能。省略 `--version` 会解析最新已发布版本;也可以显式固定版本:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# 固定版本示例
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
覆盖前请先审查新版本,因为 `--force` 会替换现有技能目录。更新后重新运行:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` 更新的是 SkillHub CLI 自身,不会更新已安装技能。已安装技能使用 `skillhub install ... --force` 刷新。
## 移除技能
先列出同一 registry 中的全部安装,确认没有其他需要保留的同 slug 技能:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
再移除本地安装:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会同时删除技能目录和本地 inventory 记录。当前版本的本地 `remove` 仅按 registry 和 slug 匹配,不按 namespace 或目录过滤;同一 registry 下所有 namespace、所有安装目录中的相同 slug 都会被移除。如果未过滤的 `skillhub list` 中存在需要保留的匹配项,请不要执行该命令;按 namespace 或目录精确移除需要后续 CLI 能力支持。
移除后,使用 `/reload-skills`、重启 Hermes 会话,或运行以下命令确认技能已消失:
```bash
hermes skills list --source local --enabled-only
```
## 可选:使用共享的 external skill 目录
如果多个 Agent 共用 `~/.agents/skills`,可以把 SkillHub 技能安装到共享目录,而不是 Hermes 主目录:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
然后把共享根目录合并到 `$HERMES_HOME/config.yaml`,不要覆盖已有的 `skills` 配置:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes 会把 external skill 与本地技能一起列出和加载。不要依赖本地技能覆盖 external skillHermes 0.18.2 会拒绝加载本地技能目录与 `external_dirs` 之间存在歧义的 `skill_view` 匹配;请改名或移除其中一个冲突副本。
> `external_dirs` 不是只读边界。只要 Hermes 进程拥有写权限Hermes 的技能管理工具就可能修改其中的文件。共享目录需要只读保护时,请使用文件系统权限或隔离的 Hermes profile。
## 兼容性与安全边界
- **格式兼容不等于运行时完全兼容。** Hermes 能读取 `SKILL.md` 和配套文件,但技能引用的 Agent 专用工具、MCP server、命令、环境变量或平台能力仍需逐项验证。
- **Hermes 将此路径识别为 local skill。** 通过 SkillHub CLI 复制到本地的技能不会经过 Hermes Skills Hub 的 community 安装扫描。安装前应查看 SkillHub 安全报告并审查技能内容,必要时使用 Hermes 的终端隔离能力。
- **保留多文件包。** 不要把多文件 SkillHub 技能改成 Hermes 0.18.2 的直接 URL 安装;该版本的 URL source 只保证单个 `SKILL.md`,而 SkillHub CLI 会解压完整包。
- **避免名称冲突。** 文件路径按 namespace 隔离仍不能解决斜杠命令冲突;同一 Hermes profile 内应保持规范化后的命令名唯一。例如 `PDF Tools``pdf_tools` 都会变成 `/pdf-tools`
- **保护凭证。** Token 只用于 SkillHub registry 访问,不应写进技能包。需要运行时 secret 的技能应遵循 Hermes 的环境变量和安全设置方式。
## 常见问题
### Hermes 列表中没有新技能
依次检查:
1. 当前会话的 `HERMES_HOME` 是否与安装时一致。
2. 最终路径下是否存在 `<skill-directory>/SKILL.md`
3. `SKILL.md` 是否包含有效的 `name``description`
4. `platforms` 等 frontmatter 是否排除了当前操作系统。
5. 执行 `/reload-skills` 或启动新会话后是否出现。
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### 安装提示 `skill already installed`
已有目录默认不会被覆盖。先审查目标版本,再增加 `--force`
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### 提示 `registry unreachable` 或下载失败
- 核对 `SKILLHUB_REGISTRY` 是否是 SkillHub 根地址。
- 先运行同一 registry 的 `skillhub search` 判断 registry 是否可达。
- 检查代理、DNS、证书和自托管服务状态。
- 短暂网络错误可以在确认服务正常后重试;不要通过关闭 TLS 校验绕过证书问题。
### 技能已列出但执行失败
检查技能引用的工具名称、shell 命令、脚本解释器、依赖包、MCP server、环境变量和操作系统限制。此类问题属于具体技能的运行时兼容性不代表 `SKILL.md` 发现链路失败。
### 能否直接运行 `hermes skills install` 安装 SkillHub 坐标?
Hermes 0.18.2 没有 SkillHub registry source不能直接解析 SkillHub 的 namespace/slug。请使用本指南中的 `skillhub install --dir ...`。如果未来需要 Hermes 内原生搜索、安装、更新和安全扫描,应单独设计 Hermes source adapter并重新定义协议和验收范围。
## 升级后的回归检查
升级 SkillHub CLI 或 Hermes 后,至少重新验证:
1. `skillhub install --dir` 仍生成 `<slug>/SKILL.md` 并保留配套文件。
2. `hermes skills list --source local --enabled-only` 能发现技能。
3. `/skill-name` 能加载 `SKILL.md` 并暴露配套文件路径;再通过 `skill_view(name, file_path)` 读取一个实际引用文件,或执行技能使用的脚本/资产验证其运行时路径。
4. `skillhub install --force` 能覆盖更新且 inventory 正常。
5. `skillhub remove` 后 Hermes 不再发现该技能。
上游参考:[Hermes Skills Systemv0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。

View file

@ -61,6 +61,7 @@ Important environment variables:
Scanner-side optional environment variables:
- `SKILL_SCANNER_LLM_API_KEY`
- `SKILL_SCANNER_LLM_BASE_URL`
- `SKILL_SCANNER_LLM_MODEL`
If the LLM variables are absent, the scanner should still run with non-LLM analyzers.

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway.
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. Upgrading does not wipe the database, so already-registered skill packages will not be lost.
## Q: Why can't administrators (admin) and regular users create namespaces?
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: When using the OpenClaw CLI, you can specify the namespace using the `<namespace>--<skill-name>` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace.
## Q: What is the recommended deployment method? Can I pull the images and deploy manually?
A: We recommend the official one-line deployment script. Pulling images and deploying manually is not recommended (manual deployment is prone to initialization issues such as being redirected back to the login page after logging in):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
The script performs a series of initialization steps. The generated runtime configuration is located at `/tmp/skillhub-runtime/` by default (containing `.env.release` and the docker-compose file).
## Q: After deployment, I enter the correct username and password but get redirected back to the login page?
A: This is most commonly seen with **manual deployment** (caused by API errors or incomplete initialization). Suggestions:
1. Switch to the one-line script above for deployment.
2. If necessary, clear and recreate the PostgreSQL data volume, then log in again.
3. If a reverse proxy is in front, verify that it forwards requests correctly.
## Q: How do I change the admin password? Why don't my config changes take effect?
A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables.
1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)).
2. Recreate the relevant containers:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize.
## Q: Is an email verification code required to change / reset a password?
A: Yes. By default, passwords are changed or reset via an email verification code, so SMTP must be configured first. See [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md). Administrators can also reset it via `.env.release`.
## Q: Can a skill have a Chinese name?
A: Skill names are generally in English; Chinese names are not currently supported (using a Chinese skill name in OpenClaw will cause an error).
## Q: Can unreviewed skills be downloaded?
A: As long as you have permission to view it, it can generally be downloaded.
## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page?
A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries.
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?
A: SkillHub has built-in security scanning. The scanner integration, task orchestration, audit persistence, and deployment integration are implemented by the iFLYTEK team; the underlying scanning service uses Cisco's [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) (Apache License 2.0, copyright Cisco).
## Q: Which version of cisco-ai-skill-scanner does SkillHub use?
A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build.
## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)?
A: A 400 usually means backend validation failed. Common causes:
- `SKILL.md` is not in the package root directory;
- `SKILL.md` frontmatter is missing `name` / `description` or is malformed;
- name or version conflict (e.g. `error.skill.publish.nameConflict`, meaning a skill with the same name is already published in that namespace) — change `name` in `SKILL.md`, use another namespace, or have an admin handle the existing skill;
- the namespace does not exist, or you are not a member of it;
- the package contains suspected tokens/secrets that the CLI cannot confirm skipping;
- file type / size / path is not allowed.
You can inspect the server logs to locate the cause:
```bash
docker logs --tail=300 <skillhub-server container> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: What directory structure does a skill package require?
A: The package root directory must contain a `SKILL.md` file, whose frontmatter must include fields such as `name` and `description`.
## Q: Publishing fails with "package validation failed / malformed input" — what do I do?
A: This error occurs while unzipping and reading file names, usually because the archive is not UTF-8 encoded (e.g. created with the built-in Windows compression tool) or contains Chinese/non-ASCII paths. Repackage using UTF-8 encoding and avoid Chinese / special-character paths.
## Q: How many files can a skill package contain? What if I hit the file-count limit?
A: The default limit is **100 files** (this is separate from the 100MB size limit). To raise it, change the `skillhub.publish.max-file-count` setting, or override it via an environment variable at deploy time:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended.
## Q: Is there a server version requirement for using the CLI (publish / download, etc.)?
A: A SkillHub server image of **v0.2.7 or later** is required for CLI features.
## Q: Does SkillHub support MySQL?
A: Currently only PostgreSQL is supported; MySQL is not supported.
## Q: Can SkillHub be used to distribute Plugins?
A: Not supported for now.
## Q: How do I check the SkillHub version? How do I customize it (e.g. change the logo)?
A:
- Check the server image version:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- Check the CLI version: `skillhub version`.
- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image.
## Q: The page loads, but the login / register APIs return 502?
A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502.
Troubleshooting order:
```bash
# 1. Check whether server is running
docker compose --env-file .env.release -f compose.release.yml ps
# 2. Look at the first error in the server startup log
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
One common startup failure is:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<your own random string, at least 32 characters>
```
Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early.
## Q: Why doesn't my configuration change take effect?
A: Two common causes:
1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`.
2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: What external dependencies does SkillHub require at runtime?
A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not.
The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default.
## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights?
A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console.
A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations.
## Q: How do I install multiple skills in bulk?
A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir/<skill-slug>/`:
```bash
target_dir=/opt/skillhub-skills
# install one by one
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# or read from a manifest file (one skill name per line)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case.
## Q: What should I do if I encounter issues?
A: You can get help through the following channels:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **Online Docs**: https://iflytek.github.io/skillhub/
- **Documentation**: Refer to the project README.md
- **Community Discussions**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message.
### Check Current Identity
```bash
@ -123,15 +125,24 @@ Output format: `namespace/slug version summary`
## Install Skills
Install coordinates accept a bare slug (resolved to `global` by default) and
three equivalent explicit namespace forms. When an explicit coordinate and
`--namespace` are both present, they must match.
```bash
# Install to auto-detected Agent directory
skillhub install pdf-parser
# Equivalent namespace coordinates
skillhub install team/my-skill
skillhub install @team/my-skill
skillhub install team--my-skill
# Choose install scope explicitly
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# Specify namespace (default: global)
# Specify a namespace for a bare slug
skillhub install pdf-parser --namespace myspace
# Specify version
@ -157,7 +168,7 @@ The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
@ -188,7 +199,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation
@ -236,9 +247,17 @@ skillhub list --json
### Remove Skills
```bash
# Remove all local installation targets
# A bare slug removes same-named local installations across namespaces
skillhub remove pdf-parser
# An explicit namespaced coordinate removes only that namespace
skillhub remove myspace/pdf-parser
skillhub remove @myspace/pdf-parser
skillhub remove myspace--pdf-parser
# Equivalent precise local removal with an explicit namespace
skillhub remove pdf-parser --namespace myspace
# Remove only specific Agent's installation
skillhub remove pdf-parser --agent codex
@ -470,12 +489,18 @@ Search published skills.
### install
```bash
skillhub install <slug> [options]
skillhub install <coordinate> [options]
```
`<coordinate>` accepts a bare slug (`my-skill`, resolved as `global/my-skill`)
or any of the equivalent explicit namespace forms: `team/my-skill`,
`@team/my-skill`, and `team--my-skill`. Use `--namespace team` to select a
non-global namespace for a bare slug. An explicit coordinate may be combined
with the same `--namespace`; a conflicting value is rejected as a usage error.
Options:
- `--scope <user|project>` — Install scope (omit for interactive prompt in TTY, or fall back to existing detection in non-TTY)
- `--namespace <slug>` — Namespace (default: `global`)
- `--namespace <slug>` — Namespace for a bare slug
- `--version <v>` — Version (default: latest)
- `--agent <profile>` — Agent profile (repeatable)
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
@ -499,7 +524,7 @@ Options:
### remove
```bash
skillhub remove <slug> [options]
skillhub remove <coordinate> [options]
```
Options:
@ -507,11 +532,16 @@ Options:
- `--all` — Remove all targets
- `--remote` — Remove remote skill
- `--hard` — Skip remote deletion confirmation
- `--namespace <slug>` — Namespace for remote deletion
- `--namespace <slug>` — Namespace for local or remote deletion
- `--registry <url>` — Registry URL
- `--token <token>` — API token
- `--json` — JSON output
An explicit namespaced coordinate (`team/my-skill`, `@team/my-skill`, or
`team--my-skill`) or `--namespace team` removes local installations only from
that namespace. For compatibility, a bare slug removes same-named local
installations across all namespaces in the current registry.
### doctor
```bash

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | No |
| oauth2-github-client-secret | GitHub OAuth secret | No |
| skill-scanner-llm-api-key | LLM API key | No |
| skill-scanner-llm-base-url | Local/custom LLM service base URL | No |
| skill-scanner-llm-model | LLM model name used by the scanner | No |
### 3. Choose Deployment Method

View file

@ -79,6 +79,8 @@ Enabling the LLM analysis engine can improve the accuracy of security detection:
| `SKILLHUB_SCANNER_USE_LLM` | Enable LLM analysis | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM provider (anthropic / openai / azure) | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API key | - |
| `SKILL_SCANNER_LLM_BASE_URL` | Local/custom LLM service base URL | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM model name | - |
### Deployment Notes

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。升级不会清空数据库,已录入的技能包不会丢失。
## Q: 为什么管理员admin和普通用户都无法创建命名空间
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: 使用 OpenClaw CLI 命令行工具时,可以通过 `<namespace>--<skill-name>` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。
## Q: 推荐的部署方式是什么?可以自己拉镜像手动部署吗?
A: 推荐使用官方一键部署脚本,不建议自己拉取镜像手动部署(手动部署容易出现登录后跳回登录页等初始化问题):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
脚本会执行一系列初始化操作,生成的运行时配置默认位于 `/tmp/skillhub-runtime/`(包含 `.env.release` 和 docker-compose 文件)。
## Q: 部署后输入正确的账号密码,却又跳回登录页?
A: 该现象多见于「手动部署」场景(接口异常或初始化未完成导致)。建议:
1. 改用上面的一键脚本部署。
2. 必要时清空 PostgreSQL 数据卷后重建再登录。
3. 若前置了反向代理,检查代理配置是否正确转发。
## Q: 如何修改 admin 密码?修改配置后不生效?
A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。
1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。
2. 重新创建相关容器:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。
## Q: 修改 / 找回密码必须使用邮箱验证码吗?
A: 是的,默认通过邮箱验证码修改或找回密码,因此需要先配置 SMTP。配置方法参考 [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md)。管理员也可在 `.env.release` 中进行重置。
## Q: skill 可以起中文名吗?
A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中使用中文 skill 名会报错)。
## Q: 未审核的 skill 可以下载吗?
A: 只要拥有可查看的权限,一般都可以下载。
## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式?
A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github``gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。
## Q: SkillHub 的安全扫描Skill Scanner是讯飞自研的吗使用什么协议
A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审计落库和部署集成由讯飞团队实现;底层扫描服务使用 Cisco 的 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner)Apache License 2.0,版权归 Cisco
## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本?
A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。
## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查?
A: 400 通常是后端校验未通过。常见原因:
- `SKILL.md` 不在技能包根目录;
- `SKILL.md` 的 frontmatter 缺少 `name` / `description` 或格式错误;
- 名称或版本冲突(如 `error.skill.publish.nameConflict`,表示该 namespace 下已存在同名的已发布技能)——可改 `SKILL.md` 里的 `name`、换一个 namespace或让管理员处理已有同名技能
- namespace 不存在,或你不是该 namespace 的成员;
- 包内含疑似 token/secretCLI 无法确认跳过;
- 文件类型 / 大小 / 路径不合规。
可用以下命令查看服务端日志定位:
```bash
docker logs --tail=300 <skillhub-server 容器名> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: 技能包的目录结构有什么要求?
A: 技能包根目录必须包含一个 `SKILL.md` 文件,且其 frontmatter 需包含 `name``description` 等字段。
## Q: 发布时报“技能包校验失败 / malformed input”怎么办
A: 该错误发生在 zip 解包读取文件名阶段,通常是压缩包不是 UTF-8 编码(例如用 Windows 自带压缩工具生成)或包内含中文路径导致。请使用 UTF-8 编码重新打包,并避免中文 / 特殊字符路径。
## Q: 技能包能包含多少个文件?提示文件数超限怎么办?
A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事)。如需放宽,修改配置项 `skillhub.publish.max-file-count`,或在部署时用环境变量覆盖:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。
## Q: 使用 CLI发布 / 下载等)对服务端版本有要求吗?
A: 需要 SkillHub 服务端镜像 **v0.2.7 及以上** 才支持 CLI 功能。
## Q: SkillHub 支持 MySQL 数据库吗?
A: 目前仅支持 PostgreSQL暂不支持 MySQL。
## Q: SkillHub 可以用来分发 Plugin 吗?
A: 暂不支持。
## Q: 如何查看 SkillHub 的版本?想做定制(如修改 logo怎么办
A:
- 查看服务端镜像版本:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- 查看 CLI 版本:`skillhub version`
- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。
## Q: 页面能打开,但登录 / 注册接口返回 502
A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时通常先检查 `server` 是否正常启动upstream 配置、DNS 或容器网络异常也可能返回 502。
排查顺序:
```bash
# 1. 看 server 是否处于运行状态
docker compose --env-file .env.release -f compose.release.yml ps
# 2. 看 server 启动日志中的第一条错误
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
一条常见的启动失败日志是:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串至少 32 个字符>
```
启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。
## Q: 改了配置为什么不生效?
A: 两个高频原因:
1. **改错了文件**`.env.release.example` 只是模板Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`
2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: SkillHub 运行时需要哪些外部依赖?
A: 必需 PostgreSQL 和 Redis对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL暂不支持 MySQL。
发布版 Compose 已内置 PostgreSQL 与 Redis默认只绑定在 `127.0.0.1`
## Q: 通过 OAuthGitHub / GitLab 等)登录的账号,如何取得管理员权限?
A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin在后台将其提升为管理员。
`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。
## Q: 如何批量安装多个技能包?
A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir/<skill-slug>/`
```bash
target_dir=/opt/skillhub-skills
# 逐个安装
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# 或从清单文件读取(每行一个技能名)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
**SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token命令会直接失败而不再回退匿名访问遇到这种情况请更新凭据或先移除无效 Token。
## Q: 遇到问题怎么办?
A: 可以通过以下方式获取帮助:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **在线文档**: https://iflytek.github.io/skillhub/
- **文档**: 参考项目 README.md
- **社区讨论**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`
API Token 请求被拒绝时CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。
### 查看当前身份
```bash
@ -123,15 +125,23 @@ skillhub search pdf --json
## 安装技能
安装坐标支持裸 slug默认解析到 `global`)和三种等价的显式 namespace
形式。显式坐标与 `--namespace` 同时出现时,两者必须一致。
```bash
# 安装到自动探测的 Agent 目录
skillhub install pdf-parser
# 等价的 namespace 坐标
skillhub install team/my-skill
skillhub install @team/my-skill
skillhub install team--my-skill
# 显式指定安装范围
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# 指定 namespace默认 global
# 为裸 slug 指定 namespace
skillhub install pdf-parser --namespace myspace
# 指定版本
@ -157,7 +167,7 @@ CLI 按以下逻辑确定安装位置:
1. 指定 `--dir`安装到该目录agent 标记为 `custom``--dir``--scope``--agent` 互斥。
2. 指定 `--scope user|project`:探测限定在该 scope 内。
- 同时指定 `--agent <profile>`:直接安装到该 profile 对应 scope 的 skills 目录。
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。在交互式 user scope 下,始终额外提供 `generic` 目标(`<home>/.agents/skills/`),可单独选择或与已探测目标同时选择。
- 该 scope 下未探测到 → fallback`--scope user` 回退到 `<home>/.agents/skills/``--scope project` 回退到 `<cwd>/.agents/skills/`
3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。
4. 三者均未指定:
@ -188,7 +198,7 @@ CLI 按以下逻辑确定安装位置:
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
对于不在列表中的 Agent使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时CLI 会回退到上表的 `_fallback_` 行。
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;`--scope user|project` 找不到匹配的 agent 目录时CLI 会回退到上表的 `_fallback_` 行。
### 安装后的文件结构
@ -236,9 +246,17 @@ skillhub list --json
### 删除技能
```bash
# 删除所有本地安装目标
# 裸 slug 删除所有 namespace 中的同名本地安装
skillhub remove pdf-parser
# 显式 namespace 坐标只删除该 namespace
skillhub remove myspace/pdf-parser
skillhub remove @myspace/pdf-parser
skillhub remove myspace--pdf-parser
# 使用 namespace 参数进行等价的精确本地删除
skillhub remove pdf-parser --namespace myspace
# 只删除指定 Agent 的安装
skillhub remove pdf-parser --agent codex
@ -470,12 +488,17 @@ skillhub search <query> [--registry <url>] [--limit <n>] [--json]
### install
```bash
skillhub install <slug> [options]
skillhub install <coordinate> [options]
```
`<coordinate>` 支持裸 slug`my-skill`,解析为 `global/my-skill`)以及
`team/my-skill``@team/my-skill``team--my-skill` 三种等价的显式
namespace 形式。裸 slug 可通过 `--namespace team` 选择非 global namespace
显式坐标可以同时传入相同的 `--namespace`,但冲突值会作为用法错误被拒绝。
选项:
- `--scope <user|project>` — 安装范围不传时TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑)
- `--namespace <slug>` — namespace默认 `global`
- `--namespace <slug>`为裸 slug 指定 namespace
- `--version <v>` — 版本(默认最新版本)
- `--agent <profile>` — Agent 配置(可重复)
- `--dir <path>` — 自定义安装目录(与 `--scope``--agent` 互斥)
@ -499,7 +522,7 @@ skillhub list [options]
### remove
```bash
skillhub remove <slug> [options]
skillhub remove <coordinate> [options]
```
选项:
@ -507,11 +530,15 @@ skillhub remove <slug> [options]
- `--all` — 删除所有目标
- `--remote` — 删除远程技能
- `--hard` — 跳过远程删除确认
- `--namespace <slug>` — 远程删除的 namespace
- `--namespace <slug>`本地或远程删除的 namespace
- `--registry <url>` — Registry URL
- `--token <token>` — API token
- `--json` — JSON 输出
显式命名空间坐标(`team/my-skill``@team/my-skill``team--my-skill`)或
`--namespace team` 只删除该 namespace 中的本地安装。为保持兼容,裸 slug
会删除当前 registry 中所有 namespace 下的同名本地安装。
### doctor
```bash

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式

View file

@ -79,6 +79,8 @@ Skill Scanner 执行多引擎分析
| `SKILLHUB_SCANNER_USE_LLM` | 启用 LLM 分析 | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM 提供商anthropic / openai / azure | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API 密钥 | - |
| `SKILL_SCANNER_LLM_BASE_URL` | 本地/自定义 LLM 服务地址 | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM 模型名称 | - |
### 部署说明

View file

@ -0,0 +1,325 @@
# CLI Namespace Errors Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every documented namespace coordinate reach the correct registry path and preserve public server error messages and request IDs without misclassifying all 403 responses as token-scope failures.
**Architecture:** Extend the shared coordinate parser with a resolver that owns explicit namespace conflict handling, then make install/remove consume it while removing the argument parser's early `global` default. Add one response-error converter inside `SkillHubClient` so JSON endpoints and downloads share safe `msg`/`requestId` extraction while retaining status-based exit codes.
**Tech Stack:** TypeScript, Bun test/build, cac, npm package tarballs.
## Completion record
Completed in PR #608 and revalidated after merging `origin/main` on 2026-07-29.
The checklist below reflects the delivered implementation. The maintainer
revalidation did not recreate historical RED states; it reran the current
GREEN gates with the repository-pinned Bun 1.3.13:
- Focused namespace/error/help regression: 142 tests passed.
- Complete CLI regression: 379 tests passed with
`bun test --max-concurrency=1` (peak RSS 180452 KiB).
- Typecheck, lint, and build passed.
- The packed `@astron-team/skillhub@0.1.9` artifact contained `dist/index.js`,
`README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`.
- Packed Node artifact smoke passed for `version`, `help install`, all three
namespaced coordinate forms, coordinate/`--namespace` conflict handling, and
structured 403 message/request-ID rendering.
- The Chinese and English VitePress documentation build passed.
---
### Task 1: Establish release artifact baseline
**Files:**
- Inspect: `cli/package.json`
- Inspect: npm package `@astron-team/skillhub@0.1.9`
- [x] **Step 1: Read published metadata and download the package**
Run:
```bash
npm view @astron-team/skillhub@0.1.9 version dist.tarball dist.integrity --json
npm pack @astron-team/skillhub@0.1.9 --pack-destination /tmp/skillhub-npm-019-issue-606 --json
```
Expected: version `0.1.9`, a tarball with `dist/index.js`, `README.md`,
`LICENSE`, and `package.json`.
- [x] **Step 2: Confirm the published bundle contains both bug signatures**
Run:
```bash
tar -xOf /tmp/skillhub-npm-019-issue-606/astron-team-skillhub-0.1.9.tgz package/dist/index.js | rg 'indexOf\("--"\)|token may lack required scope'
```
Expected: both patterns are present, proving 0.1.9 includes the double-dash
parser but also the misleading 403 fallback.
### Task 2: Normalize coordinates and reject conflicts
**Files:**
- Modify: `cli/test/unit/shared/skill-name-parser.test.ts`
- Modify: `cli/src/shared/skill-name-parser.ts`
- [x] **Step 1: Replace permissive edge tests with the public coordinate matrix**
Add table-driven assertions for `my-skill`, `team/my-skill`,
`@team/my-skill`, and `team--my-skill`. Add resolver assertions for an explicit
namespace on a bare slug, a matching coordinate namespace, and a conflicting
namespace. Add malformed-input assertions for empty or incomplete coordinates.
- [x] **Step 2: Run the parser test and verify RED**
Run:
```bash
cd cli && bun test test/unit/shared/skill-name-parser.test.ts
```
Expected: failures for slash forms, malformed input, and the missing resolver.
- [x] **Step 3: Implement the minimal parser and resolver**
Keep `ParsedSkillName` unchanged. Add `resolveSkillName(skillName,
explicitNamespace?)` returning `ParsedSkillName`. It calls one internal parser,
applies `global` only to bare slugs, accepts a matching explicit namespace, and
throws `CliError(..., EXIT.usage)` on malformed input or conflict.
- [x] **Step 4: Run the parser test and verify GREEN**
Run:
```bash
cd cli && bun test test/unit/shared/skill-name-parser.test.ts
```
Expected: all parser tests pass with no warnings.
### Task 3: Wire the resolver through real CLI parsing
**Files:**
- Modify: `cli/src/commands/install.ts`
- Modify: `cli/src/commands/remove.ts`
- Modify: `cli/src/index.ts`
- Modify: `cli/test/unit/commands/install-command.test.ts`
- Modify: `cli/test/integration/install-command.test.ts`
- [x] **Step 1: Add failing command and integration tests**
Capture `installSkill` options in the unit test and assert a namespaced
coordinate passes `namespace: 'team'` and `slug: 'my-skill'`. In the integration
test, register a `team/my-skill` fixture and execute:
```text
skillhub install @team/my-skill --dir <temp> --registry <fake> --token sk_ok --json
```
Assert exit 0, JSON namespace `team`, and fake-registry resolve state
`{ namespace: 'team', slug: 'my-skill' }`. Add a conflicting
`--namespace other` case that exits with usage code 5 before registry access.
- [x] **Step 2: Run the focused command tests and verify RED**
Run:
```bash
cd cli && bun test test/unit/commands/install-command.test.ts test/integration/install-command.test.ts
```
Expected: the namespaced integration case resolves `global` or fails, and the
conflict case does not produce the expected usage error.
- [x] **Step 3: Use `resolveSkillName` and remove the cac default**
Change install/remove to call:
```typescript
const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace)
```
Change install's option declaration to:
```typescript
.option('--namespace <slug>', 'Namespace for a bare skill slug')
```
- [x] **Step 4: Run the focused command tests and verify GREEN**
Run the same Bun test command. Expected: all focused command tests pass.
### Task 4: Preserve structured API errors and request IDs
**Files:**
- Modify: `cli/test/unit/clients/skillhub-client.test.ts`
- Modify: `cli/test/unit/shared/output.test.ts`
- Modify: `cli/src/clients/skillhub-client.ts`
- Modify: `cli/src/shared/output.ts`
- [x] **Step 1: Add failing response and output tests**
Add client tests for:
```typescript
Response.json(
{ code: 403, msg: 'token has been revoked', requestId: 'req-403' },
{ status: 403 }
)
```
Assert message `token has been revoked`, auth exit code, and details containing
`requestId: 'req-403'`. Add 403 tests without `msg`, with invalid JSON, and a
404 with structured fields. Add a download 403 structured-response test. Add a
human output assertion for `Request ID: req-403`.
- [x] **Step 2: Run focused tests and verify RED**
Run:
```bash
cd cli && bun test test/unit/clients/skillhub-client.test.ts test/unit/shared/output.test.ts
```
Expected: structured messages/request IDs are discarded and human output omits
the request ID.
- [x] **Step 3: Implement one safe response-error converter**
Inside `SkillHubClient`, add a private method that reads non-success bodies once,
parses only object-shaped JSON, accepts only non-empty string `msg` and
`requestId`, selects status-specific fallback text and exit codes, and returns a
`CliError`. Use it from both `handleJsonResponse` and `download`. Do not add the
old token-scope hint to 403 errors. Update `renderError` with:
```typescript
if (typeof cliError.details.requestId === 'string') {
lines.push(`Request ID: ${cliError.details.requestId}`)
}
```
- [x] **Step 4: Run focused tests and verify GREEN**
Run the same focused Bun test command. Expected: all client/output tests pass.
### Task 5: Document the public contract and release impact
**Files:**
- Modify: `cli/src/commands/help.ts`
- Modify: `cli/README.md`
- Create: `cli/CHANGELOG.md`
- Modify: `cli/package.json`
- Modify: `cli/test/integration/help-command.test.ts`
- [x] **Step 1: Add a failing help assertion**
Assert `skillhub help install` includes `@team/my-skill`,
`team/my-skill`, and `team--my-skill` examples.
- [x] **Step 2: Run the help test and verify RED**
Run:
```bash
cd cli && bun test test/integration/help-command.test.ts
```
Expected: the coordinate examples are absent.
- [x] **Step 3: Update help, README, and release notes**
Use `<coordinate>` in install usage. Document all accepted forms and the
same-namespace/conflict rule. Add an Unreleased changelog entry covering
coordinate normalization and structured 403 messages/request IDs. Include
`CHANGELOG.md` in the npm package `files` list.
- [x] **Step 4: Run the help test and verify GREEN**
Run the same Bun test command. Expected: all help tests pass.
### Task 6: Verify source, build, and packed artifact
**Files:**
- Verify: all files changed by Tasks 2-5
- Produce locally: `cli/dist/index.js`
- Produce locally: npm tarball under `/tmp`
- [x] **Step 1: Run the complete CLI quality gate**
Run:
```bash
cd cli && bun test
cd cli && bun run typecheck
cd cli && bun run lint
cd cli && bun run build
```
Expected: every command exits 0 with no errors or warnings.
- [x] **Step 2: Pack and inspect the candidate artifact**
Run:
```bash
cd cli && npm pack --pack-destination /tmp/skillhub-cli-issue-606 --json
tar -tf /tmp/skillhub-cli-issue-606/astron-team-skillhub-0.1.9.tgz
```
Expected: the package contains the built executable, README, changelog,
license, and package metadata.
- [x] **Step 3: Run packed-bundle smoke checks**
Extract the tarball to a temporary directory and run the built executable's
`version` and `help install` commands. Expected: version reports 0.1.9 and help
shows every coordinate form. Run the relevant unit/integration suites against
source to verify request paths and structured errors.
- [x] **Step 4: Review the diff and commit**
Run:
```bash
git diff --check
git status --short
git diff --stat
```
Expected: only CLI implementation/tests/docs and the two planning documents are
changed; generated `cli/dist/index.js` and tarballs are not committed.
Commit with a conventional message containing the issue ID:
```bash
git commit -m "fix(cli): normalize namespace coordinates and errors (#606)"
```
### Task 7: Review and create the single final PR
**Files:**
- Review: committed diff against `origin/main`
- [x] **Step 1: Run tester and reviewer gates**
The tester must confirm focused and full CLI gates plus package smoke evidence.
The reviewer must inspect coordinate compatibility, error disclosure, test
coverage, docs, commit metadata, and absence of unrelated changes. Resolve all
blocking findings before continuing.
- [x] **Step 2: Push only the assigned branch**
Run:
```bash
git push -u origin fix/cli-namespace-errors
```
Expected: only the assigned branch is created or updated remotely.
- [x] **Step 3: Create one PR linked to the issue**
Create one PR titled `fix(cli): normalize namespace coordinates and errors`
with `Related to #606` in the body, complete test/package evidence, docs and
risk sections, and no close intent unless the project manager requests it.
Do not merge the PR.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
# CLI Namespace Coordinates and Structured Errors Design
## Context and approval
GitHub issue #606 reports two coupled CLI 0.1.9 failures: namespaced install
coordinates can silently resolve against `global`, and JSON API responses with
HTTP 403 are always rewritten as a token-scope error. The Multica issue's
technical-analysis comment defines the desired normalization, conflict, error,
documentation, and package-verification behavior. The project manager then
assigned implementation against that design on `fix/cli-namespace-errors`, so
that comment and assignment are the approved design baseline.
## Considered approaches
1. Centralize coordinate normalization and structured response errors in the
existing shared parser and client. This is the selected approach because all
commands receive one interpretation and tests can exercise the public
contract without duplicating parsing or status handling.
2. Patch `install` only. This would be smaller, but `remove --remote` already
consumes the same parser and would retain inconsistent behavior.
3. Change the server or documentation to accept only `--namespace`. This would
preserve the CLI bug and contradict documented coordinate forms.
## Coordinate contract
The CLI accepts these equivalent inputs:
| Input | Namespace | Slug |
|---|---|---|
| `my-skill` | `global` | `my-skill` |
| `team/my-skill` | `team` | `my-skill` |
| `@team/my-skill` | `team` | `my-skill` |
| `team--my-skill` | `team` | `my-skill` |
| `my-skill --namespace team` | `team` | `my-skill` |
The command parser must not inject `global` before coordinate normalization.
`global` is applied only when the input is a bare slug and no explicit
`--namespace` is supplied. If a coordinate and `--namespace` name the same
namespace, the input is accepted. If they differ, the command fails with a
usage error instead of silently choosing either value.
Structurally incomplete coordinates such as an empty string, `@team`,
`team/`, `/my-skill`, `--my-skill`, and `team--` fail with a usage error. The
normalizer does not add new namespace or slug character restrictions; server
validation remains authoritative for those rules.
## Error contract
For unsuccessful JSON API responses, the client reads the body once and only
uses the documented public fields `msg` and `requestId` when they are non-empty
strings. A server `msg` becomes the `CliError` message. A `requestId` is stored
in error details and rendered in both JSON and human-readable CLI output.
Exit classification remains stable:
- 401 and 403 use the authentication exit code.
- 404 and other application failures use the generic exit code.
- 502 and 503 use the network exit code.
When `msg` is absent, invalid, or the body is not JSON, the CLI uses a status-
specific fallback. In particular, the 403 fallback is `access denied` and does
not speculate about token scope. Raw non-JSON bodies and unrecognized fields
are not surfaced, avoiding disclosure of internal response content. Download
responses use the same structured error extraction while retaining their
download-specific fallbacks.
## Components and data flow
- `cli/src/shared/skill-name-parser.ts` parses and resolves coordinates,
including explicit namespace conflict detection.
- `cli/src/commands/install.ts` and `cli/src/commands/remove.ts` consume the
resolved coordinate.
- `cli/src/index.ts` leaves `--namespace` unset unless the caller supplies it.
- `cli/src/clients/skillhub-client.ts` converts unsuccessful responses into
structured `CliError` instances.
- `cli/src/shared/output.ts` renders `requestId` for human users; JSON output
already serializes error details.
- `cli/src/commands/help.ts`, `cli/README.md`, and `cli/CHANGELOG.md` document
supported forms, conflicts, and the 403 behavior change.
## Testing and package verification
Unit tests cover the coordinate matrix, malformed inputs, matching/conflicting
`--namespace`, structured and unstructured 401/403/404/500/502 responses, and
human request-ID rendering. An integration install test executes the real CLI
argument parser against a fake registry so the former `default: 'global'`
override cannot regress.
The release check builds and packs the CLI, inspects the tarball file list, and
runs the packed executable for version/help plus focused coordinate/error smoke
tests. The published npm 0.1.9 package is retained only as a comparison
artifact; no package publication or main-branch merge is part of this work.
## Risks
- Rejecting ambiguous coordinate/flag combinations is an intentional behavior
tightening and is called out in release notes.
- Server `msg` is treated as the public localized message defined by the API
envelope. Raw body content is deliberately not exposed.
- This change does not publish a new npm version; release owners must verify the
future dist-tag after the approved PR is merged and released.

View file

@ -0,0 +1,279 @@
# Revoked API Token Validation Design
## Goal
Prove and preserve fail-closed API-token behavior across the CLI API using a
real persisted token lifecycle. Invalid Bearer credentials must return HTTP
401 before endpoint business logic runs, including when a valid Web Session is
also present. A valid Bearer credential overrides the Session identity. When
Bearer is absent or the Authorization scheme is unsupported, the existing Web
Session identity is preserved; without a valid Session, public reads remain
anonymous and `whoami` returns 401. Valid credentials without sufficient
authorization continue to return HTTP 403.
## Scope
This change covers the following CLI routes:
- `GET /api/cli/v1/auth/whoami`
- `GET /api/cli/v1/skills/search`
- `GET /api/cli/v1/skills/{namespace}/{slug}/resolve`
- `GET /api/cli/v1/skills/{namespace}/{slug}/download`
- `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download`
It also covers the authenticated-versus-forbidden boundary on the affected
restricted read routes. An existing scope-protected CLI route may provide
supplementary scope-filter evidence only. This change does not add endpoints,
change runtime response fields, change token storage, add a database migration,
or change anonymous resource visibility rules. The OpenAPI correction marks
the already-nullable `whoami.email` value accurately without changing its JSON
field presence.
## Current-State Finding
The fail-closed implementation from closed PR #511 was later included in the
single replacement PR #523 and is present in both v0.2.14 and current `main`.
`ApiTokenAuthenticationFilter` already validates Bearer credentials before
business logic and rejects empty, malformed, unknown, expired, revoked,
missing-user, and disabled-user credentials through the configured
`AuthenticationEntryPoint`.
The verified repository gap is regression coverage, not a demonstrated
production-code gap. Existing tests separately prove token lifecycle
validation and invalid-Bearer filtering, but they do not exercise persisted
token creation, revocation, and all affected CLI endpoints in one integrated
matrix. The CLI API table in `docs/03-authentication-design.md` also retains
legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in
`docs/api/`.
The reported v0.2.14 runtime behavior still contradicts the source and test
evidence. Source equality alone does not establish which artifact or replica
served the reported requests. The defect therefore remains open until the
release artifact and affected runtime are identified and the same token
lifecycle is replayed against that identified runtime.
## Release Artifact and Runtime Identity Gate
Runtime verification is a required investigation track, not an optional
deployment check. Before interpreting a runtime result, record all of the
following for every server replica that may receive the request:
1. The configured deployment version and resolved image reference from the
runtime environment and `docker compose config --images`.
2. The running container's image ID and registry `RepoDigest` from
`docker inspect` / `docker image inspect`.
3. The OCI `org.opencontainers.image.revision` and
`org.opencontainers.image.version` labels. The publish workflow generates
these labels and also publishes a `sha-<short-sha>` tag, so the revision can
be mapped back to a repository commit.
4. The externally observed application URL, health result, deployment profile,
and request IDs for the authentication probes.
If the revision label is absent, the image digest must be mapped to the
corresponding publish-images workflow output or registry manifest. A mutable
tag such as `latest` or `v0.2.14` is not sufficient identity evidence by
itself. If neither a revision nor a digest-to-build mapping can be obtained,
the source/runtime contradiction is unresolved and the defect cannot be
closed.
Using a dedicated test user and non-production token, replay one lifecycle
against the identified running image:
1. Issue the token and call every matrix endpoint while it is valid.
2. Revoke that same token through the normal product flow and verify its
persisted `revoked_at` value without exposing the raw token.
3. Reuse the same raw token against every matrix endpoint and capture status,
response envelope, request ID, timestamp, and serving replica when
available.
4. Repeat or pin requests per replica when a load balancer can route to mixed
versions, and compare the image digest/revision of each replica.
If production mutation is not authorized, run the exact identified digest in
an approved isolated environment with equivalent auth/proxy configuration and
record that limitation. This does not by itself close the original field
report: an authorized runtime replay or owner-provided equivalent evidence is
still required.
The contradiction is closed only when source commit, published image digest,
running instance identity, and replay result form one consistent chain. A
mismatched digest indicates deployment drift; identical application images
with divergent behavior require investigation of proxy header forwarding,
mixed replicas, session/cookie contamination, and request routing before any
source-code conclusion is accepted.
## Architecture
`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry
point. Spring Security loads an existing Web Session identity before the token
filter runs. A valid Bearer token replaces that identity; an invalid, empty, or
malformed Bearer attempt clears it and returns 401. The filter ignores Basic
and other non-Bearer schemes, preserving the loaded Session identity. If no
Session exists, those schemes reach public reads anonymously and `whoami`
returns 401. Controllers must not duplicate token parsing, Session resolution,
or lifecycle checks.
The regression test will boot the Spring application with MockMvc, real
`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI
endpoint business services may be mocked only to make successful public-read
responses deterministic; authentication and token lifecycle components remain
real. This isolates the contract boundary under test: a rejected credential
must stop in the security chain before controller business logic executes.
The restricted-read authorization test is separate and must not mock the
permission decision. It will persist a PRIVATE or NAMESPACE_ONLY skill owned by
another user, authenticate a valid outsider token with no qualifying namespace
role, and exercise the real `CliSkillAppService` plus domain query/download
authorization path. At least `resolve`, latest download, and versioned download
must return HTTP 403. A DELETE request with a missing token scope may supplement
this check, but cannot replace any affected read-path assertion.
Production authentication code will be changed only when a new regression
test fails for the expected behavioral reason. Any fix must be the smallest
change at the shared authentication or token-validation source of the failure.
Endpoint-specific authentication patches and unrelated refactoring are out of
scope.
## Persisted Token Lifecycle
The test fixture creates an active user and issues a token through
`ApiTokenService`, retaining only the raw token returned at creation time.
Lifecycle transitions use production persistence paths:
1. Call an affected endpoint with the valid raw token and confirm successful
authentication.
2. Revoke the token through `ApiTokenService.revokeToken`.
3. Call every affected endpoint with the same raw token.
4. Assert HTTP 401 and confirm protected endpoint business logic was not
reached.
Expired-token coverage persists a token with an expiration timestamp earlier
than the service clock, then validates it through the same filter and
repository path. Unknown and malformed tokens exercise the same HTTP security
chain without creating a token row.
## Behavioral Matrix
The authentication rows use deterministic public fixtures. Latest and
versioned downloads are independent endpoints and must have independent test
arguments and assertions for every credential state.
| Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning |
|---|---:|---:|---:|---:|---:|---|
| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public |
| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts |
| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved |
| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity |
| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected |
| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity |
| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic |
| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic |
The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and
the real read-authorization path:
| Valid credential, insufficient resource permission | `whoami` | `search` | Restricted `resolve` | Restricted latest download | Restricted versioned download |
|---|---:|---:|---:|---:|---:|
| Outsider token with no qualifying namespace role | 200 | 200 with restricted skill omitted | 403 | 403 | 403 |
The same fixture must also prove that an authorized owner or qualifying
namespace member can reach the restricted read path, so a 403 cannot be caused
by an invalid fixture. Missing-scope DELETE coverage is optional supplementary
evidence for the API-token scope filter only.
## Error Handling and Security
- Invalid Bearer credentials return the existing structured HTTP 401 response
through `ApiAuthenticationEntryPoint`.
- Valid credentials that fail scope or resource authorization return the
existing structured HTTP 403 response through the access-denied path.
- Responses must not reveal whether a token is unknown, expired, or revoked.
- Tests, logs, documentation, and commits must not contain real secrets. Test
credentials are generated locally and exist only in the in-memory test
database.
- Token material must never be logged.
## Documentation
Two documentation updates are required:
1. Update `docs/03-authentication-design.md` so the CLI API section uses the
current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session
priority, Session fallback, and the anonymous/401/403 boundary.
2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document
must define Bearer and Web Session authentication, all affected paths,
query/path parameters, success schemas, the common response envelope, HTTP
401 and 403 responses, examples, credential priority, and the rule that
requests without either identity are allowed only on existing public-read
routes. `CliWhoAmI.email` remains required but is nullable.
No controller signature or response schema changes are planned. Therefore the
generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a
production fix unexpectedly changes a controller contract, `make generate-api`
becomes mandatory and the generated diff must be committed.
## Implementation Plan Requirements
The detailed implementation plan must preserve the following independent
steps rather than collapsing them into one generic download case:
1. Create the real persisted token/user fixture and public endpoint stubs used
by the authentication matrix.
2. Exercise `whoami`, `search`, and `resolve` for every credential state.
3. Exercise latest download for every credential state.
4. Exercise versioned download for every credential state.
5. Persist a restricted skill plus authorized and unauthorized users, then use
the real read-authorization path to prove 403 for restricted `resolve`,
latest download, and versioned download and success for an authorized user.
6. Update the authentication design and OpenAPI contract.
7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or
invalid Bearer independently on all five endpoints; latest and versioned
download remain separate cases.
8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result
and assert the fixed five-field 403 envelope on each restricted read.
9. Identify the published/running image and replay the valid-to-revoked token
lifecycle against that exact digest, or record the external access blocker
without treating the field contradiction as resolved.
Each endpoint/state step must state its own expected status and test command.
The plan may share fixture helpers, but it must not share one assertion in a
way that can skip either download route.
## Verification
Verification proceeds in this order:
1. Run the new focused persisted-token matrix and record whether it fails or
passes on unmodified `main` behavior, with separate results for latest and
versioned download.
2. Run the persisted restricted-resource checks through real query/download
authorization and record outsider 403 plus authorized-user success.
3. If an authentication row fails, preserve the failure output as reproduction
evidence, apply one minimal shared fix, and rerun the focused matrix.
4. Run auth-module and affected app integration tests.
5. Run `make test-backend-app`.
6. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates.
7. Run `make staging` for containerized regression and smoke coverage.
8. Run `git diff --check` and confirm no generated OpenAPI type drift when no
controller contract changed.
9. Record the release tag, build revision, image reference, immutable digest,
and every serving replica's running image identity.
10. Replay the same valid-to-revoked token lifecycle against the identified
runtime and record endpoint-level status, request ID, and replica evidence,
keeping latest and versioned download results separate.
11. Perform structured security and code review before updating the existing
single final pull request.
## Delivery Constraints
- Work only on `fix/auth-revoked-token-validation`.
- Keep PR #511 closed and use it only as historical reference.
- Create exactly one final pull request for GitHub issue #605.
- GitHub-facing text must not contain a Multica issue identifier.
- Do not mark the defect resolved or eligible for closure while the reported
runtime behavior and the identified artifact/runtime replay remain
contradictory or incomplete.
- Do not merge `main`; merging remains the responsibility of an explicitly
authorized human owner.

View file

@ -1,10 +1,16 @@
FROM python:3.11-alpine
ARG SKILL_SCANNER_VERSION=1.0.2
WORKDIR /app
RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev && \
pip install --no-cache-dir cisco-ai-skill-scanner && \
apk del .build-deps && \
COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py
RUN pip install --no-cache-dir \
"cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \
"litellm==1.90.2" && \
python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \
rm /tmp/apply_1_0_2_llm_base_url_backport.py && \
addgroup -S app && \
adduser -S app -G app && \
mkdir -p /tmp/skillhub-scans && \

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2."""
from __future__ import annotations
import re
import sys
from pathlib import Path
EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info"
ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py")
def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str:
actual_count = content.count(old)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.")
return content.replace(old, new, expected_count)
def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str:
updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.")
return updated
def main() -> int:
site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages")
dist_info = site_packages / EXPECTED_DIST_INFO
if not dist_info.exists():
raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.")
router_path = site_packages / ROUTER_RELATIVE_PATH
content = router_path.read_text(encoding="utf-8")
content = replace_regex(
content,
r'^(?P<indent>\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$',
r'\g<0>\n\g<indent>llm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")',
2,
"llm_model environment lookup",
)
content = replace_exact(
content,
"LLMAnalyzer(model=llm_model)",
"LLMAnalyzer(model=llm_model, base_url=llm_base_url)",
2,
"LLMAnalyzer model constructor",
)
content = replace_exact(
content,
"LLMAnalyzer(provider=provider_str)",
"LLMAnalyzer(provider=provider_str, base_url=llm_base_url)",
2,
"LLMAnalyzer provider constructor",
)
router_path.write_text(content, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TEMPLATE="$REPO_ROOT/web/nginx.conf.template"
NGINX_IMAGE="${NGINX_TEST_IMAGE:-nginx:alpine}"
TEST_ID="skillhub-nginx-forwarded-proto-$$"
NETWORK="${TEST_ID}-network"
BACKEND="${TEST_ID}-backend"
DEFAULT_PROXY="${TEST_ID}-default"
TRUSTED_PROXY="${TEST_ID}-trusted"
TMP_DIR="$(mktemp -d)"
CONTAINERS=()
cleanup() {
if ((${#CONTAINERS[@]} > 0)); then
docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true
fi
docker network rm "$NETWORK" >/dev/null 2>&1 || true
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
wait_for_nginx() {
local container="$1"
local attempt
for attempt in {1..30}; do
if docker exec "$container" wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1; then
return 0
fi
sleep 0.2
done
docker logs "$container" >&2 || true
fail "$container did not become healthy"
}
start_proxy() {
local container="$1"
local trust_forwarded_proto="$2"
docker run --detach \
--name "$container" \
--network "$NETWORK" \
--env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \
--env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \
--volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$container")
wait_for_nginx "$container"
}
assert_proto() {
local container="$1"
local expected="$2"
local header="${3:-}"
local path="${4:-/api/proto}"
local actual
if [[ -n "$header" ]]; then
actual="$(docker exec "$container" wget -qO- \
--header="X-Forwarded-Proto: $header" \
"http://127.0.0.1$path")"
else
actual="$(docker exec "$container" wget -qO- "http://127.0.0.1$path")"
fi
[[ "$actual" == "$expected" ]] \
|| fail "$container forwarded proto '$actual', expected '$expected' for $path with header '${header:-<none>}'"
}
cat >"$TMP_DIR/backend.conf" <<'EOF'
server {
listen 8080;
location / {
default_type text/plain;
return 200 $http_x_forwarded_proto;
}
}
EOF
docker network create "$NETWORK" >/dev/null
docker run --detach \
--name "$BACKEND" \
--network "$NETWORK" \
--volume "$TMP_DIR/backend.conf:/etc/nginx/conf.d/default.conf:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$BACKEND")
start_proxy "$DEFAULT_PROXY" false
start_proxy "$TRUSTED_PROXY" true
for path in /api/proto /oauth2/proto /login/oauth2/proto /.well-known/proto; do
assert_proto "$DEFAULT_PROXY" http https "$path"
assert_proto "$TRUSTED_PROXY" https https "$path"
done
assert_proto "$TRUSTED_PROXY" http
assert_proto "$TRUSTED_PROXY" http "https,http"
echo "nginx-forwarded-proto-test passed"

View file

@ -0,0 +1,232 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCANNER_DIR="$REPO_ROOT/scanner"
TMP_DIRS=()
cleanup() {
local status=$?
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
exit "$status"
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp="$(new_tmp)"
skill_dir="$tmp/skill"
mkdir -p "$skill_dir/demo-skill"
cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF'
---
name: demo-skill
description: Minimal valid skill used for scanner integration coverage.
license: Apache-2.0
---
This is a harmless demo skill used for scanner integration testing.
EOF
cat >"$skill_dir/demo-skill/run.sh" <<'EOF'
#!/usr/bin/env sh
echo "demo"
EOF
chmod +x "$skill_dir/demo-skill/run.sh"
IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)"
docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null
docker run --rm -i \
-v "$skill_dir:/work/skill:ro" \
--entrypoint python \
"$IMAGE_TAG" - <<'PY'
import asyncio
from datetime import datetime, timezone
import http.server
import io
import inspect
import json
import os
from pathlib import Path
import threading
import urllib.request
import zipfile
from fastapi.params import Query
from skill_scanner.core.models import ScanResult
import skill_scanner.api.router as router
signature = inspect.signature(router.scan_uploaded_skill)
if not isinstance(signature.parameters["use_llm"].default, Query):
raise SystemExit("scan-upload use_llm should remain a Query parameter")
if not isinstance(signature.parameters["llm_provider"].default, Query):
raise SystemExit("scan-upload llm_provider should remain a Query parameter")
state = {"base_urls": [], "paths": []}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A003
return
def do_POST(self): # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
state["paths"].append(self.path)
payload = json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": int(datetime.now(timezone.utc).timestamp()),
"model": "local-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "No findings."},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
target_base_url = f"http://127.0.0.1:{server.server_port}/v1"
os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url
os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model"
class FakeStaticAnalyzer:
pass
class FakeLLMAnalyzer:
def __init__(self, model=None, provider=None, base_url=None):
self.model = model
self.provider = provider
self.base_url = base_url
state["base_urls"].append(base_url)
def analyze(self, skill_path):
request = urllib.request.Request(
self.base_url + "/chat/completions",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
class FakeSkillScanner:
def __init__(self, analyzers):
self.analyzers = analyzers
def scan_skill(self, skill_path):
for analyzer in self.analyzers:
analyze = getattr(analyzer, "analyze", None)
if callable(analyze):
analyze(skill_path)
return ScanResult(
skill_name="demo-skill",
skill_directory=str(skill_path),
findings=[],
scan_duration_seconds=0.05,
analyzers_used=["fake-llm"],
timestamp=datetime.now(timezone.utc),
)
router.StaticAnalyzer = FakeStaticAnalyzer
router.LLMAnalyzer = FakeLLMAnalyzer
router.SkillScanner = FakeSkillScanner
router.LLM_AVAILABLE = True
request = router.ScanRequest(
skill_directory="/work/skill/demo-skill",
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
def build_skill_archive_bytes(skill_root: str) -> bytes:
skill_path = Path(skill_root)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in skill_path.rglob("*"):
if path.is_file():
archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes())
return buffer.getvalue()
class FakeUploadFile:
def __init__(self, filename: str, payload: bytes):
self.filename = filename
self._payload = payload
async def read(self) -> bytes:
return self._payload
try:
direct_response = asyncio.run(router.scan_skill(request))
upload_response = asyncio.run(
router.scan_uploaded_skill(
file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")),
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
)
finally:
server.shutdown()
thread.join(timeout=5)
if not getattr(direct_response, "scan_id", None):
raise SystemExit("scan_skill should still return a scan response")
if not getattr(upload_response, "scan_id", None):
raise SystemExit("scan_uploaded_skill should still return a scan response")
if len(state["base_urls"]) != 2:
raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}")
if any(base_url != target_base_url for base_url in state["base_urls"]):
raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}")
if len(state["paths"]) != 2:
raise SystemExit(f"expected two LLM requests, got {state['paths']}")
if not all(path.startswith("/v1/") for path in state["paths"]):
raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}")
PY
grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \
|| fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL"
grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \
|| fail "Kubernetes secret example must document skill-scanner-llm-base-url"
echo "scanner-llm-base-url-test passed"

View file

@ -36,6 +36,7 @@ POSTGRES_USER=skillhub
POSTGRES_PASSWORD=strong-postgres-password
SESSION_COOKIE_SECURE=true
BOOTSTRAP_ADMIN_ENABLED=false
SKILLHUB_TRUST_FORWARDED_PROTO=false
SKILLHUB_STORAGE_PROVIDER=s3
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
SKILLHUB_STORAGE_S3_BUCKET=skillhub
@ -80,6 +81,11 @@ short_env="$tmp/short.env"
write_env "$short_env" "too-short"
expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"
invalid_forwarded_proto_env="$tmp/invalid-forwarded-proto.env"
write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in

View file

@ -24,6 +24,7 @@ assert_pr_workflow_hardened() {
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-helm-chart.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml"
assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW"
assert_pr_workflow_hardened "$SECURITY_WORKFLOW"
@ -54,8 +55,12 @@ grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR CLI workflow changes"
grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR E2E workflow changes"
grep -Fq '.github/workflows/pr-helm-chart.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR Helm Chart workflow changes"
grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR Tests workflow changes"
grep -Fq '.github/workflows/publish-chart.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when Chart publish workflow changes"
grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when Python source changes"
grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \
@ -64,8 +69,14 @@ grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env draft changes"
grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release compose changes"
grep -Fq 'web/Dockerfile' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the web image changes"
grep -Fq 'web/nginx.conf.template' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the nginx template changes"
grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run validate-release-config-test"
grep -Fq 'bash scripts/tests/nginx-forwarded-proto-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run nginx-forwarded-proto-test"
grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run runtime-secret-test"
grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \

View file

@ -151,6 +151,7 @@ reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
validate_boolean SESSION_COOKIE_SECURE
validate_boolean BOOTSTRAP_ADMIN_ENABLED
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET

View file

@ -5,6 +5,7 @@ import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
@ -18,14 +19,20 @@ import java.util.List;
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient(RedisProperties redisProperties) {
return Redisson.create(createConfig(redisProperties));
public RedissonClient redissonClient(
RedisProperties redisProperties,
@Value("${skillhub.redis.sentinel.check-sentinels-list:true}") boolean checkSentinelsList) {
return Redisson.create(createConfig(redisProperties, checkSentinelsList));
}
static Config createConfig(RedisProperties redisProperties) {
return createConfig(redisProperties, true);
}
static Config createConfig(RedisProperties redisProperties, boolean checkSentinelsList) {
Config config = new Config();
if (hasSentinelConfiguration(redisProperties)) {
configureSentinelServers(config, redisProperties);
configureSentinelServers(config, redisProperties, checkSentinelsList);
return config;
}
@ -38,10 +45,14 @@ public class RedissonConfig {
return config;
}
private static void configureSentinelServers(Config config, RedisProperties redisProperties) {
private static void configureSentinelServers(
Config config,
RedisProperties redisProperties,
boolean checkSentinelsList) {
SentinelServersConfig sentinelServersConfig = config.useSentinelServers()
.setMasterName(redisProperties.getSentinel().getMaster())
.setDatabase(redisProperties.getDatabase());
.setDatabase(redisProperties.getDatabase())
.setCheckSentinelsList(checkSentinelsList);
List<String> nodes = redisProperties.getSentinel().getNodes();
nodes.stream()
.map(String::trim)
@ -50,6 +61,9 @@ public class RedissonConfig {
.forEach(sentinelServersConfig::addSentinelAddress);
applySharedSettings(sentinelServersConfig, redisProperties);
if (StringUtils.hasText(redisProperties.getSentinel().getPassword())) {
sentinelServersConfig.setSentinelPassword(redisProperties.getSentinel().getPassword());
}
}
private static boolean hasSentinelConfiguration(RedisProperties redisProperties) {

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
@ -56,6 +57,7 @@ public class AuthController extends BaseApiController {
private final UserRoleBindingRepository userRoleBindingRepository;
private final PlatformSessionService platformSessionService;
private final UserAccountRepository userAccountRepository;
private final AuthMeResponseAssembler authMeResponseAssembler;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
@ -64,7 +66,8 @@ public class AuthController extends BaseApiController {
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
@ -73,6 +76,7 @@ public class AuthController extends BaseApiController {
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
this.authMeResponseAssembler = authMeResponseAssembler;
}
/**
@ -111,7 +115,7 @@ public class AuthController extends BaseApiController {
freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
/**
@ -146,7 +150,7 @@ public class AuthController extends BaseApiController {
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
);
}
@ -178,7 +182,7 @@ public class AuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
authMeResponseAssembler.from(principal)
);
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController {
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService) {
PasswordResetService passwordResetService,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
}
@PostMapping("/register")
@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
return ok("response.success.created", authMeResponseAssembler.from(principal));
}
@PostMapping("/login")
@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
@PostMapping("/change-password")

View file

@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionRequestDto;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
@ -79,11 +81,19 @@ public class PromotionController extends BaseApiController {
}
@GetMapping
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@RequestParam(defaultValue = "PENDING") String status,
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@Parameter(schema = @Schema(allowableValues = {"PENDING", "APPROVED", "REJECTED"}, defaultValue = "PENDING"))
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(schema = @Schema(allowableValues = {"reviewedAt"}))
@RequestParam(required = false) String sortBy,
@Parameter(schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "DESC"))
@RequestParam(required = false) String sortDirection,
@RequestAttribute("userId") String userId) {
return ok("response.success.read", governanceWorkflowAppService.listPromotions(status, page, size, userId));
return ok(
"response.success.read",
governanceWorkflowAppService.listPromotions(status, page, size, sortBy, sortDirection, userId)
);
}
@GetMapping("/pending")

View file

@ -10,15 +10,17 @@ public record AuthMeResponse(
String email,
String avatarUrl,
String oauthProvider,
boolean canChangePassword,
Set<String> platformRoles
) {
public static AuthMeResponse from(PlatformPrincipal principal) {
public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) {
return new AuthMeResponse(
principal.userId(),
principal.displayName(),
principal.email() != null ? principal.email() : "",
principal.avatarUrl() != null ? principal.avatarUrl() : "",
principal.oauthProvider(),
canChangePassword,
principal.platformRoles()
);
}

View file

@ -5,9 +5,15 @@ import java.time.Instant;
public record PromotionResponseDto(
Long id,
Long sourceSkillId,
String sourceSkillDisplayName,
String sourceSkillSummary,
String sourceNamespace,
String sourceSkillSlug,
String sourceVersion,
Integer sourceVersionFileCount,
Long sourceVersionTotalSize,
Long sourceSkillDownloadCount,
Integer sourceSkillStarCount,
String targetNamespace,
Long targetSkillId,
String status,

View file

@ -200,9 +200,15 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
return new PromotionResponseDto(
request.getId(),
request.getSourceSkillId(),
skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(),
skill.getSummary(),
sourceNamespace.getSlug(),
skill.getSlug(),
version.getVersion(),
version.getFileCount(),
version.getTotalSize(),
skill.getDownloadCount(),
skill.getStarCount(),
targetNamespace.getSlug(),
request.getTargetSkillId(),
request.getStatus().name(),

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
@ -38,14 +39,25 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ApiTokenAccessDeniedException apiTokenException =
accessDeniedException instanceof ApiTokenAccessDeniedException typedException
? typedException
: null;
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
"Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]",
MDC.get("requestId"),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName()
accessDeniedException.getClass().getSimpleName(),
apiTokenException != null ? apiTokenException.getMessage() : null
);
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");
ApiResponse<Void> body = apiTokenException != null
? apiResponseFactory.error(
403,
apiTokenException.getMessageCode(),
apiTokenException.getMessageArgs()
)
: apiResponseFactory.error(403, "error.forbidden");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);

View file

@ -37,6 +37,8 @@ import java.util.stream.Collectors;
public class AdminUserAppService {
private static final Set<UserStatus> MANAGEABLE_STATUSES = Set.of(UserStatus.ACTIVE, UserStatus.DISABLED);
private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN";
private static final String USER_ROLE = "USER";
private final AdminUserSearchRepository adminUserSearchRepository;
private final UserAccountRepository userAccountRepository;
@ -83,15 +85,17 @@ public class AdminUserAppService {
UserAccount user = loadUser(userId);
rejectSystemAccountMutation(user);
String normalizedRoleCode = normalizeRoleCode(roleCode);
boolean targetHasSuperAdminRole = userRoleBindingRepository.findByUserId(user.getId()).stream()
.anyMatch(binding -> SUPER_ADMIN_ROLE.equals(binding.getRole().getCode()));
if ("SUPER_ADMIN".equals(normalizedRoleCode)
&& (actorPlatformRoles == null || !actorPlatformRoles.contains("SUPER_ADMIN"))) {
if ((SUPER_ADMIN_ROLE.equals(normalizedRoleCode) || targetHasSuperAdminRole)
&& (actorPlatformRoles == null || !actorPlatformRoles.contains(SUPER_ADMIN_ROLE))) {
throw new DomainForbiddenException("error.admin.user.role.superAdmin.assignDenied");
}
userRoleBindingRepository.deleteByUserId(user.getId());
if (!"USER".equals(normalizedRoleCode)) {
if (!USER_ROLE.equals(normalizedRoleCode)) {
Role role = roleRepository.findByCode(normalizedRoleCode)
.orElseThrow(() -> new DomainBadRequestException("error.admin.user.role.invalid", roleCode));
userRoleBindingRepository.save(new UserRoleBinding(user.getId(), role));

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