mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
Merge remote-tracking branch 'origin/main' into review/pr443-fix
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
commit
bbf9e4e714
290 changed files with 18776 additions and 2153 deletions
|
|
@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
|
|||
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
|
||||
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
||||
|
||||
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
|
||||
|
|
|
|||
|
|
@ -79,6 +79,15 @@ SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
|
|||
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
|
||||
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
|
||||
|
||||
# Direct (username/password) authentication for environments without OAuth2.
|
||||
# To enable, set BOTH:
|
||||
# - SKILLHUB_AUTH_DIRECT_ENABLED=true (server: enables the /api/v1/auth/direct endpoint)
|
||||
# - SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true (web: surfaces the username/password form)
|
||||
# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to the provider id (e.g. "local").
|
||||
SKILLHUB_AUTH_DIRECT_ENABLED=false
|
||||
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false
|
||||
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=
|
||||
|
||||
# SMTP configuration for password reset verification emails.
|
||||
SPRING_MAIL_HOST=
|
||||
SPRING_MAIL_PORT=587
|
||||
|
|
@ -95,6 +104,10 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
|
|||
# Security scanner is enabled by default. Set to false to disable scanning.
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=true
|
||||
|
||||
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
|
||||
# runtime.sh generates and persists one automatically when this placeholder is still present.
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
|
||||
|
||||
# Scanner LLM configuration (optional, for AI-powered scanning features)
|
||||
SKILL_SCANNER_LLM_API_KEY=
|
||||
SKILL_SCANNER_LLM_BASE_URL=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,2 @@
|
|||
# https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github
|
||||
have_fun: false # Just review the code
|
||||
code_review:
|
||||
comment_severity_threshold: HIGH # Reduce quantity of comments
|
||||
pull_request_opened:
|
||||
summary: false # Don't summarize the PR in a separate comment
|
||||
disable: true
|
||||
|
|
|
|||
5
.github/workflows/pr-cli.yml
vendored
5
.github/workflows/pr-cli.yml
vendored
|
|
@ -7,6 +7,9 @@ on:
|
|||
- 'Makefile'
|
||||
- '.github/workflows/pr-cli.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli:
|
||||
strategy:
|
||||
|
|
@ -16,6 +19,8 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
|
|
|||
2
.github/workflows/pr-e2e.yml
vendored
2
.github/workflows/pr-e2e.yml
vendored
|
|
@ -34,6 +34,8 @@ jobs:
|
|||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
|
|
|||
37
.github/workflows/pr-scripts.yml
vendored
Normal file
37
.github/workflows/pr-scripts.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: PR Scripts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.env.release.example'
|
||||
- '.env.release.draft'
|
||||
- 'compose.release.yml'
|
||||
- 'Makefile'
|
||||
- '.github/workflows/pr-cli.yml'
|
||||
- '.github/workflows/pr-e2e.yml'
|
||||
- '.github/workflows/pr-tests.yml'
|
||||
- '.github/workflows/security.yml'
|
||||
- '.github/workflows/pr-scripts.yml'
|
||||
- '**/*.py'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scripts-tests:
|
||||
name: Script Regression Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '21'
|
||||
- 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/dev-web-host-test.sh
|
||||
- run: bash scripts/tests/workflow-security-test.sh
|
||||
40
.github/workflows/pr-tests.yml
vendored
40
.github/workflows/pr-tests.yml
vendored
|
|
@ -25,6 +25,8 @@ jobs:
|
|||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
|
@ -52,6 +54,8 @@ jobs:
|
|||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
|
|
@ -65,3 +69,39 @@ jobs:
|
|||
|
||||
- name: Run backend unit tests
|
||||
run: make test-backend
|
||||
|
||||
docs-build:
|
||||
name: Docs Build
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect docs changes
|
||||
id: changed
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/skillhub/**'
|
||||
- '.github/workflows/pr-tests.yml'
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: docs/skillhub/package-lock.json
|
||||
|
||||
- name: Install docs dependencies
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
run: cd docs/skillhub && npm ci
|
||||
|
||||
- name: Build VitePress site
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
run: cd docs/skillhub && npm run build
|
||||
|
|
|
|||
87
.github/workflows/security.yml
vendored
Normal file
87
.github/workflows/security.yml
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
name: Security
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '23 3 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Review dependency changes
|
||||
uses: actions/dependency-review-action@v4
|
||||
|
||||
codeql:
|
||||
name: CodeQL (${{ matrix.language }})
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java
|
||||
if: matrix.language == 'java-kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
cache: maven
|
||||
|
||||
- name: Ensure Maven wrapper is executable
|
||||
if: matrix.language == 'java-kotlin'
|
||||
run: chmod +x server/mvnw
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Build Java for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
run: cd server && ./mvnw -q -DskipTests package
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
13
Makefile
13
Makefile
|
|
@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid
|
|||
DEV_SERVER_LOG := $(DEV_DIR)/server.log
|
||||
DEV_WEB_LOG := $(DEV_DIR)/web.log
|
||||
DEV_WEB_URL := http://localhost:3000
|
||||
DEV_WEB_HOST ?= 127.0.0.1
|
||||
DEV_API_URL := http://localhost:8080
|
||||
DEV_SCANNER_URL := http://localhost:8000
|
||||
STAGING_API_URL := http://localhost:8080
|
||||
|
|
@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
else \
|
||||
echo "Starting frontend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \
|
||||
fi
|
||||
@echo "Waiting for backend on $(DEV_API_URL) ..."
|
||||
@backend_ready=0; \
|
||||
|
|
@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
|
||||
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
|
||||
|
||||
dev-server-restart: ## 重启后端开发服务器
|
||||
@mkdir -p $(DEV_DIR)
|
||||
|
|
@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖
|
|||
cd web && CI=true pnpm install --frozen-lockfile
|
||||
|
||||
dev-web: ## 启动前端开发服务器
|
||||
cd web && pnpm run dev
|
||||
cd web && pnpm exec vite --host $(DEV_WEB_HOST)
|
||||
|
||||
build-frontend: web-deps ## 构建前端
|
||||
cd web && pnpm run build
|
||||
|
|
@ -278,13 +279,13 @@ lint-cli: ## CLI 代码检查
|
|||
typecheck-cli: ## CLI 类型检查
|
||||
cd cli && bun run typecheck
|
||||
|
||||
publish-cli: ## 发布 CLI(patch 版本)- bump + tag + push,触发 CI 自动发布
|
||||
publish-cli: ## 发布 CLI(patch 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI
|
||||
./scripts/publish-cli.sh patch
|
||||
|
||||
publish-cli-minor: ## 发布 CLI(minor 版本)- bump + tag + push,触发 CI 自动发布
|
||||
publish-cli-minor: ## 发布 CLI(minor 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI
|
||||
./scripts/publish-cli.sh minor
|
||||
|
||||
publish-cli-major: ## 发布 CLI(major 版本)- bump + tag + push,触发 CI 自动发布
|
||||
publish-cli-major: ## 发布 CLI(major 版本)- 本地 build+test → 推 release 分支 → 开 PR,合并后手动 tag 触发 CI
|
||||
./scripts/publish-cli.sh major
|
||||
|
||||
db-reset: ## 重置数据库
|
||||
|
|
|
|||
|
|
@ -436,6 +436,12 @@ 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)**
|
||||
|
||||
### [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.
|
||||
|
|
|
|||
|
|
@ -370,6 +370,12 @@ 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)**
|
||||
|
||||
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
|
||||
|
||||
[AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -127,6 +130,10 @@ Output format: `namespace/slug version summary`
|
|||
# Install to auto-detected Agent directory
|
||||
skillhub install pdf-parser
|
||||
|
||||
# Choose install scope explicitly
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# Specify namespace (default: global)
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
|
|
@ -150,18 +157,21 @@ skillhub install pdf-parser --force
|
|||
|
||||
The CLI determines the installation location using the following logic:
|
||||
|
||||
1. If `--dir` is specified: Install to that directory, agent marked as `custom`
|
||||
2. If `--agent` is specified: Install to the corresponding Agent's skills directory
|
||||
3. If neither is specified: Auto-scan current directory to detect existing Agent config directories
|
||||
- 1 Agent detected → Install directly
|
||||
- Multiple Agents detected → Interactive selection (TTY mode) or error (non-interactive mode)
|
||||
- No Agent detected → Fallback to `<cwd>/.agents/skills/`
|
||||
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. 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:
|
||||
- **Interactive mode** (stdin and stdout are both TTY, no `--json`): Prompt for `user` or `project` scope first, then continue per the `--scope` rule above.
|
||||
- **Non-interactive mode**: Auto-scan current directory to detect existing Agent config directories. 1 Agent detected → install directly; multiple → error; none detected → fallback to `<cwd>/.agents/skills/`.
|
||||
|
||||
> `--dir` and `--agent` cannot be used together.
|
||||
> `--dir` cannot be combined with `--scope` or `--agent`.
|
||||
|
||||
### Install Paths
|
||||
|
||||
Each Agent has both project-level and user-level skills directories:
|
||||
Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used.
|
||||
|
||||
| Agent | Project-level Path | User-level Path |
|
||||
|-------|-------------------|-----------------|
|
||||
|
|
@ -169,9 +179,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
|
||||
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
|
||||
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
|
||||
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
|
||||
|
|
@ -179,8 +189,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `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.
|
||||
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
|
||||
|
||||
|
|
@ -325,8 +336,8 @@ 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> [--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 <slug> [--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 doctor [--json]` | Scan project directory and rebuild local inventory |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
|
||||
## Overview
|
||||
|
||||
CLI releases are fully automated. Running `make publish-cli` on a clean `main` branch bumps the version, commits, creates a `cli-vX.Y.Z` tag, and pushes everything to origin. The GitHub Actions workflow [`release-cli.yml`](../.github/workflows/release-cli.yml) listens for the tag and handles build, test, npm publish, and GitHub Release creation.
|
||||
CLI releases use a PR-based flow. Running `make publish-cli` on a clean `main` branch:
|
||||
|
||||
1. Runs local build-and-test (lint, typecheck, test, build)
|
||||
2. Computes the next version from the latest `cli-v*` tag on `origin`
|
||||
3. Creates a `release/cli-vX.Y.Z` branch with the version bump committed
|
||||
4. Pushes the branch and opens a PR to `main`
|
||||
|
||||
After the PR is merged, you manually tag and push — the tag triggers [`release-cli.yml`](../.github/workflows/release-cli.yml) which builds, publishes to npm, and creates a GitHub Release.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -21,8 +28,9 @@ Configure in GitHub repository → Settings → Secrets and variables → Action
|
|||
|
||||
### Local Environment
|
||||
|
||||
- `node` and `npm` installed (the script uses `npm version` to bump)
|
||||
- `git` installed with push access to the repository
|
||||
- `node` and `bun` installed
|
||||
- `gh` CLI installed and authenticated (`gh auth login`)
|
||||
- `git` with push access to the repository
|
||||
- On the `main` branch with a clean working tree
|
||||
|
||||
### Package Configuration
|
||||
|
|
@ -40,7 +48,7 @@ In [`cli/package.json`](./package.json):
|
|||
|
||||
## Release Process
|
||||
|
||||
### One-shot Release
|
||||
### Step 1: Run the publish script
|
||||
|
||||
From the repository root, on a clean `main` branch:
|
||||
|
||||
|
|
@ -52,15 +60,31 @@ make publish-cli-major # major: 0.1.5 -> 1.0.0
|
|||
|
||||
[`scripts/publish-cli.sh`](../scripts/publish-cli.sh) performs the following steps:
|
||||
|
||||
1. Verify the working tree is clean
|
||||
2. Require the current branch to be `main`, otherwise abort
|
||||
1. Verify `gh` CLI is installed and authenticated
|
||||
2. Verify the working tree is clean and on `main`
|
||||
3. `git pull --ff-only` from `origin/main`
|
||||
4. Fetch remote tags and align `package.json` with the latest `cli-v*` tag
|
||||
5. Compute the new version via `npm version <bump>`
|
||||
6. Verify the new tag does not exist locally or on origin
|
||||
7. After interactive confirmation: commit the bump, create the `cli-vX.Y.Z` tag, push both commit and tag to origin
|
||||
4. Run full local build-and-test (lint, typecheck, test, build)
|
||||
5. Compute the next version from the latest `cli-v*` tag on `origin` (via `git ls-remote`, so local orphan tags from a failed `git push origin cli-vX.Y.Z` are ignored)
|
||||
6. Verify the tag and release branch don't already exist
|
||||
7. After interactive confirmation: create release branch, commit version bump, push, and open PR
|
||||
|
||||
Pushing the tag triggers CI — no further manual action required.
|
||||
### Step 2: Merge the PR
|
||||
|
||||
Review and merge the PR on GitHub as usual.
|
||||
|
||||
### Step 3: Tag and push
|
||||
|
||||
After the PR is merged:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git tag cli-vX.Y.Z origin/main # replace with the actual version
|
||||
git push origin cli-vX.Y.Z
|
||||
```
|
||||
|
||||
This ensures the tag is always placed on the merge commit on `origin/main`, regardless of your local branch state.
|
||||
|
||||
Pushing the tag triggers CI which builds, publishes to npm, and creates a GitHub Release.
|
||||
|
||||
### CI Workflow
|
||||
|
||||
|
|
@ -104,6 +128,22 @@ From the Actions UI:
|
|||
2. Enter an existing tag name matching `cli-vX.Y.Z`
|
||||
3. Optionally enable skip npm publish
|
||||
|
||||
## Error Recovery
|
||||
|
||||
The script uses a cleanup state machine. If it fails at different stages:
|
||||
|
||||
- **Before push**: release branch is deleted locally, you're returned to `main`
|
||||
- **After push, before PR**: the script prints recovery instructions (open PR manually or delete the remote branch)
|
||||
- **After PR opened**: success — no cleanup needed
|
||||
|
||||
If you need to manually clean up a failed release:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git branch -D release/cli-vX.Y.Z # delete local branch
|
||||
git push origin --delete release/cli-vX.Y.Z # delete remote branch (if pushed)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `releases must be cut from 'main'`
|
||||
|
|
@ -118,6 +158,10 @@ Commit or stash local changes first.
|
|||
|
||||
The previous release didn't clean up, or someone else released the same version. Check `git tag --list 'cli-v*'` and remote tags, then retry with a higher version.
|
||||
|
||||
### `branch release/cli-vX.Y.Z already exists`
|
||||
|
||||
A previous release attempt left a stale branch. Delete it locally and/or on origin, then retry.
|
||||
|
||||
### npm Publish Fails
|
||||
|
||||
- **403 with 2FA message**: `NPM_TOKEN` is not an Automation Token, or bypass 2FA is not enabled — regenerate with the correct type
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@astron-team/skillhub",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"description": "Manage and install skills for AI coding agents",
|
||||
"keywords": [
|
||||
"skillhub",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { homedir } from 'node:os'
|
||||
import { CliError } from '../shared/errors'
|
||||
import { EXIT } from '../shared/constants'
|
||||
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
|
||||
import type { AgentCandidate } from './types'
|
||||
import { allProfiles, profileMap } from './detector'
|
||||
|
||||
|
|
@ -9,20 +10,31 @@ export interface ResolveInstallTargetOptions {
|
|||
home?: string | undefined
|
||||
dir?: string | undefined
|
||||
agents?: string[] | undefined
|
||||
scope?: 'user' | 'project' | undefined
|
||||
json: boolean
|
||||
interactive: boolean
|
||||
detected?: AgentCandidate[] | undefined
|
||||
}
|
||||
|
||||
export async function resolveInstallTargets(options: ResolveInstallTargetOptions): Promise<AgentCandidate[]> {
|
||||
if (options.dir && options.agents?.length) {
|
||||
const agentList = options.agents ?? []
|
||||
|
||||
if (options.dir && agentList.length > 0) {
|
||||
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
|
||||
}
|
||||
if (options.dir && options.scope !== undefined) {
|
||||
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
|
||||
}
|
||||
if (options.dir) {
|
||||
return [{ agent: 'custom', rootDir: options.dir, scope: 'user', source: 'explicit' }]
|
||||
}
|
||||
if (options.agents?.length) {
|
||||
const resolved = await resolveExplicitAgents(options.agents, options.cwd, options.home ?? homedir())
|
||||
|
||||
if (options.scope !== undefined) {
|
||||
return resolveScopedTargets(options, agentList)
|
||||
}
|
||||
|
||||
if (agentList.length > 0) {
|
||||
const resolved = await resolveExplicitAgents(agentList, options.cwd, options.home ?? homedir())
|
||||
return dedupeByRoot(resolved)
|
||||
}
|
||||
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? '')
|
||||
|
|
@ -39,6 +51,68 @@ export async function resolveInstallTargets(options: ResolveInstallTargetOptions
|
|||
return [{ agent: 'generic', rootDir: `${options.cwd}/.agents/skills`, scope: 'project', source: 'fallback' }]
|
||||
}
|
||||
|
||||
async function resolveScopedTargets(
|
||||
options: ResolveInstallTargetOptions,
|
||||
agentList: string[]
|
||||
): Promise<AgentCandidate[]> {
|
||||
const scope = options.scope!
|
||||
const scopedHome = options.home ?? homedir()
|
||||
|
||||
let candidates: AgentCandidate[]
|
||||
if (agentList.length > 0) {
|
||||
candidates = await resolveExplicitAgents(agentList, options.cwd, scopedHome, scope)
|
||||
} else if (options.detected !== undefined) {
|
||||
candidates = options.detected.filter(c => c.scope === scope)
|
||||
} else {
|
||||
candidates = await generateScopedCandidates(scope, options.cwd, scopedHome)
|
||||
}
|
||||
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'
|
||||
? `${scopedHome}/.agents/skills`
|
||||
: `${options.cwd}/.agents/skills`
|
||||
return [{ agent: 'generic', rootDir: fallbackRoot, scope, source: 'fallback' }]
|
||||
}
|
||||
if (candidates.length === 1) return candidates
|
||||
if (options.interactive && !options.json) {
|
||||
return selectTargetsInteractively(candidates)
|
||||
}
|
||||
throw new CliError('multiple install targets detected', EXIT.usage, {
|
||||
next: 'pass --agent or --dir',
|
||||
candidates
|
||||
})
|
||||
}
|
||||
|
||||
async function generateScopedCandidates(
|
||||
scope: 'user' | 'project',
|
||||
cwd: string,
|
||||
home: string
|
||||
): Promise<AgentCandidate[]> {
|
||||
const results: AgentCandidate[] = []
|
||||
for (const profile of allProfiles) {
|
||||
const roots = scope === 'user' ? profile.userRoots(home) : profile.projectRoots(cwd)
|
||||
for (const root of roots) {
|
||||
if (await pathExists(root)) {
|
||||
results.push({ agent: profile.id, rootDir: root, scope, source: 'detected' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
|
||||
const results: AgentCandidate[] = []
|
||||
for (const profile of allProfiles) {
|
||||
|
|
@ -48,7 +122,12 @@ async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
|
|||
return dedupeByRoot(results)
|
||||
}
|
||||
|
||||
async function resolveExplicitAgents(agents: string[], cwd: string, home?: string): Promise<AgentCandidate[]> {
|
||||
async function resolveExplicitAgents(
|
||||
agents: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
scope?: 'user' | 'project'
|
||||
): Promise<AgentCandidate[]> {
|
||||
const results: AgentCandidate[] = []
|
||||
for (const agentId of agents) {
|
||||
const profile = profileMap.get(agentId)
|
||||
|
|
@ -57,34 +136,48 @@ async function resolveExplicitAgents(agents: string[], cwd: string, home?: strin
|
|||
next: 'use a supported agent profile or pass --dir'
|
||||
})
|
||||
}
|
||||
const userRoots = home ? profile.userRoots(home) : []
|
||||
const roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
|
||||
if (roots.length > 0) {
|
||||
results.push(...roots.map(root => {
|
||||
const scope: AgentCandidate['scope'] = root.startsWith(cwd) ? 'project' : 'user'
|
||||
return {
|
||||
let roots: string[]
|
||||
if (scope === 'user') {
|
||||
roots = profile.userRoots(home)
|
||||
} else if (scope === 'project') {
|
||||
roots = profile.projectRoots(cwd)
|
||||
} else {
|
||||
const userRoots = home ? profile.userRoots(home) : []
|
||||
roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
|
||||
}
|
||||
const userRootSet = new Set(home ? profile.userRoots(home) : [])
|
||||
for (const root of roots) {
|
||||
const candidateScope: AgentCandidate['scope'] = scope !== undefined
|
||||
? scope
|
||||
: (userRootSet.has(root) ? 'user' : 'project')
|
||||
results.push({
|
||||
agent: agentId,
|
||||
rootDir: root,
|
||||
scope,
|
||||
source: 'explicit' as const
|
||||
}
|
||||
}))
|
||||
scope: candidateScope,
|
||||
source: 'explicit'
|
||||
})
|
||||
}
|
||||
}
|
||||
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[]> {
|
||||
const prompts = await import('prompts')
|
||||
let highlightedIndex = 0
|
||||
const { selected } = await prompts.default({
|
||||
type: 'multiselect',
|
||||
name: 'selected',
|
||||
|
|
@ -92,7 +185,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise
|
|||
choices: candidates.map(c => ({
|
||||
title: `${c.agent} (${c.rootDir})`,
|
||||
value: c
|
||||
}))
|
||||
})),
|
||||
onRender: function (this: { cursor?: number }) {
|
||||
highlightedIndex = this.cursor ?? highlightedIndex
|
||||
},
|
||||
format: (selectedTargets: AgentCandidate[]) => (
|
||||
selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!]
|
||||
)
|
||||
})
|
||||
if (!selected || selected.length === 0) {
|
||||
throw new CliError('installation cancelled', EXIT.usage)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ export interface PublishResponse {
|
|||
visibility: string
|
||||
}
|
||||
|
||||
export interface DryRunResponse {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
resolvedSlug: string | null
|
||||
resolvedVersion: string | null
|
||||
}
|
||||
|
||||
interface ErrorEnvelope {
|
||||
msg?: unknown
|
||||
requestId?: unknown
|
||||
}
|
||||
|
||||
export class SkillHubClient {
|
||||
constructor(
|
||||
readonly registry: string,
|
||||
|
|
@ -80,9 +93,12 @@ 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) {
|
||||
if (response.status === 401) {
|
||||
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw await this.createAccessDeniedError(response)
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry })
|
||||
}
|
||||
|
|
@ -113,6 +129,23 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
|
||||
method: 'POST',
|
||||
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
||||
body: formData
|
||||
})
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
@ -126,9 +159,12 @@ export class SkillHubClient {
|
|||
}
|
||||
|
||||
private async handleJsonResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
if (response.status === 401) {
|
||||
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw await this.createAccessDeniedError(response)
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
|
||||
}
|
||||
|
|
@ -144,6 +180,26 @@ export class SkillHubClient {
|
|||
return body.data as T
|
||||
}
|
||||
|
||||
private async createAccessDeniedError(response: Response): Promise<CliError> {
|
||||
const error = await this.readErrorEnvelope(response)
|
||||
return new CliError(error.message ?? 'access denied', EXIT.auth, {
|
||||
registry: this.registry,
|
||||
...(error.requestId ? { requestId: error.requestId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
private async readErrorEnvelope(response: Response): Promise<{ message?: string; requestId?: string }> {
|
||||
try {
|
||||
const body = await response.json() as ErrorEnvelope
|
||||
return {
|
||||
...(typeof body.msg === 'string' && body.msg.trim() ? { message: body.msg } : {}),
|
||||
...(typeof body.requestId === 'string' && body.requestId.trim() ? { requestId: body.requestId } : {})
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): HeadersInit {
|
||||
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +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> [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
|
||||
examples: ['skillhub install pdf-parser', 'skillhub install pdf-parser --agent codex']
|
||||
usage: 'skillhub install <slug> [--scope <user|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
|
||||
examples: [
|
||||
'skillhub install pdf-parser',
|
||||
'skillhub install pdf-parser --scope user',
|
||||
'skillhub install pdf-parser --scope project --agent codex'
|
||||
]
|
||||
},
|
||||
list: {
|
||||
summary: 'List local installs',
|
||||
|
|
|
|||
|
|
@ -3,34 +3,113 @@ import { CredentialsStore } from '../stores/credentials-store'
|
|||
import { resolveRegistry, resolveToken } from '../services/registry-service'
|
||||
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'
|
||||
|
||||
export interface InstallCommandOptions {
|
||||
namespace?: string | undefined
|
||||
version?: string | undefined
|
||||
agent?: string[] | undefined
|
||||
dir?: string | undefined
|
||||
scope?: string | undefined
|
||||
force?: boolean | undefined
|
||||
registry?: string | undefined
|
||||
token?: string | undefined
|
||||
json?: boolean | undefined
|
||||
}
|
||||
|
||||
export async function installCommand(slug: string, options: InstallCommandOptions): Promise<string> {
|
||||
export interface InstallCommandDeps {
|
||||
promptScope?: () => Promise<'user' | 'project'>
|
||||
resolveInstallTargets?: typeof resolveInstallTargets
|
||||
installSkill?: typeof installSkill
|
||||
isTTY?: () => boolean
|
||||
}
|
||||
|
||||
export function computeStrictIsTTY(env: {
|
||||
stdinIsTTY: boolean
|
||||
stdoutIsTTY: boolean
|
||||
json: boolean
|
||||
}): boolean {
|
||||
return env.stdinIsTTY && env.stdoutIsTTY && !env.json
|
||||
}
|
||||
|
||||
export async function resolveEffectiveScope(
|
||||
options: InstallCommandOptions,
|
||||
env: { isTTY: boolean; promptScope: () => Promise<'user' | 'project'> }
|
||||
): Promise<'user' | 'project' | undefined> {
|
||||
if (options.scope !== undefined && options.scope !== 'user' && options.scope !== 'project') {
|
||||
throw new CliError('--scope must be "user" or "project"', EXIT.usage)
|
||||
}
|
||||
const scope = options.scope as 'user' | 'project' | undefined
|
||||
const agentList = options.agent ?? []
|
||||
|
||||
if (options.dir && scope !== undefined) {
|
||||
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
|
||||
}
|
||||
if (options.dir && agentList.length > 0) {
|
||||
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
|
||||
}
|
||||
|
||||
if (scope !== undefined) return scope
|
||||
if (options.dir || agentList.length > 0) return undefined
|
||||
if (env.isTTY) return await env.promptScope()
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function defaultPromptScope(): Promise<'user' | 'project'> {
|
||||
const prompts = await import('prompts')
|
||||
const { scope } = await prompts.default({
|
||||
type: 'select',
|
||||
name: 'scope',
|
||||
message: 'Install for user or project?',
|
||||
choices: [
|
||||
{ title: 'User (install to user-level agent directory)', value: 'user' },
|
||||
{ title: 'Project (install to project-level agent directory)', value: 'project' }
|
||||
]
|
||||
})
|
||||
if (!scope) {
|
||||
throw new CliError('installation cancelled', EXIT.usage)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
export async function installCommand(
|
||||
skillNameArg: string,
|
||||
options: InstallCommandOptions,
|
||||
deps: InstallCommandDeps = {}
|
||||
): Promise<string> {
|
||||
const isTTYFn = deps.isTTY ?? (() => computeStrictIsTTY({
|
||||
stdinIsTTY: process.stdin.isTTY === true,
|
||||
stdoutIsTTY: process.stdout.isTTY === true,
|
||||
json: Boolean(options.json)
|
||||
}))
|
||||
const isTTY = isTTYFn()
|
||||
|
||||
const promptScope = deps.promptScope ?? defaultPromptScope
|
||||
const effectiveScope = await resolveEffectiveScope(options, { isTTY, promptScope })
|
||||
|
||||
const configStore = new ConfigStore()
|
||||
const credentialsStore = new CredentialsStore()
|
||||
const registry = resolveRegistry(options, process.env, await configStore.read())
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
const namespace = options.namespace ?? 'global'
|
||||
|
||||
const targets = await resolveInstallTargets({
|
||||
const parsed = parseSkillName(skillNameArg)
|
||||
const namespace = options.namespace ?? parsed.namespace
|
||||
const slug = parsed.slug
|
||||
|
||||
const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets
|
||||
const targets = await resolveTargets({
|
||||
cwd: process.cwd(),
|
||||
scope: effectiveScope,
|
||||
dir: options.dir,
|
||||
agents: options.agent ?? [],
|
||||
json: Boolean(options.json),
|
||||
interactive: process.stdout.isTTY === true
|
||||
interactive: isTTY
|
||||
})
|
||||
|
||||
const result = await installSkill({
|
||||
const installFn = deps.installSkill ?? installSkill
|
||||
const result = await installFn({
|
||||
registry, token, namespace, slug,
|
||||
version: options.version,
|
||||
targets,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface PublishCommandOptions {
|
|||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
|
||||
|
|
@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
let archiveBlob: Blob
|
||||
let archiveName: string
|
||||
if (pathStat.isFile()) {
|
||||
// If input is a file, check if it's already a zip
|
||||
if (await isZipFile(path)) {
|
||||
const buffer = await readFile(path)
|
||||
archiveBlob = new Blob([buffer], { type: 'application/zip' })
|
||||
|
|
@ -49,7 +49,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
|
||||
}
|
||||
} else if (pathStat.isDirectory()) {
|
||||
// If input is a directory, create zip from it
|
||||
archiveBlob = await createZip(path)
|
||||
archiveName = `${basename(path)}.zip`
|
||||
} else {
|
||||
|
|
@ -57,6 +56,49 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
}
|
||||
|
||||
const client = new SkillHubClient(registry, token)
|
||||
|
||||
if (options.dryRun) {
|
||||
const result = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
|
||||
|
||||
if (options.json) {
|
||||
if (!result.valid) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n')
|
||||
throw new CliError('validation failed', EXIT.validation)
|
||||
}
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
if (result.valid) {
|
||||
lines.push('Validation passed')
|
||||
} else {
|
||||
lines.push('Validation failed')
|
||||
}
|
||||
if (result.resolvedSlug) {
|
||||
lines.push(` Slug: ${result.resolvedSlug}`)
|
||||
}
|
||||
if (result.resolvedVersion) {
|
||||
lines.push(` Version: ${result.resolvedVersion}`)
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
lines.push('Errors:')
|
||||
for (const error of result.errors) {
|
||||
lines.push(` - ${error}`)
|
||||
}
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push('Warnings:')
|
||||
for (const warning of result.warnings) {
|
||||
lines.push(` - ${warning}`)
|
||||
}
|
||||
}
|
||||
if (!result.valid) {
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
throw new CliError('validation failed', EXIT.validation)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
|
||||
|
||||
const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}`
|
||||
|
|
|
|||
|
|
@ -5,6 +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'
|
||||
|
||||
export interface RemoveCommandOptions {
|
||||
agent?: string[] | undefined
|
||||
|
|
@ -17,7 +18,7 @@ export interface RemoveCommandOptions {
|
|||
json?: boolean | undefined
|
||||
}
|
||||
|
||||
export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise<string> {
|
||||
export async function removeCommand(skillNameArg: string, options: RemoveCommandOptions): Promise<string> {
|
||||
if (options.all && options.agent?.length) {
|
||||
throw new CliError('--all cannot be used with --agent', EXIT.usage)
|
||||
}
|
||||
|
|
@ -29,9 +30,12 @@ export async function removeCommand(slug: string, options: RemoveCommandOptions)
|
|||
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
|
||||
|
||||
if (options.remote) {
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
const namespace = options.namespace ?? 'global'
|
||||
|
||||
if (!options.hard && process.stdout.isTTY) {
|
||||
const prompts = await import('prompts')
|
||||
|
|
|
|||
|
|
@ -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.6"
|
||||
export const PKG_VERSION = "0.1.9"
|
||||
|
|
|
|||
|
|
@ -223,9 +223,10 @@ 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))
|
||||
})
|
||||
|
||||
|
|
@ -233,6 +234,7 @@ cli
|
|||
.command('install <slug>', 'Install a skill locally')
|
||||
.option('--namespace <slug>', 'Namespace', { default: 'global' })
|
||||
.option('--version <v>', 'Version')
|
||||
.option('--scope <scope>', 'Install scope: user or project')
|
||||
.option('--agent <profile>', 'Agent profile (repeatable)')
|
||||
.option('--dir <path>', 'Install directory')
|
||||
.option('--force', 'Overwrite existing')
|
||||
|
|
@ -278,6 +280,7 @@ cli
|
|||
.command('publish <path>', 'Publish a local skill package')
|
||||
.option('--namespace <slug>', 'Namespace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
|
||||
.option('--dry-run', 'Validate without publishing')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { zipSync, unzipSync } from 'fflate'
|
||||
import { MAX_PACKAGE_BYTES } from './download'
|
||||
|
||||
const MAX_ZIP_ENTRIES = 500
|
||||
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
|
||||
const EOCD_SIGNATURE = 0x06054b50
|
||||
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
|
||||
const ZIP64_MARKER_16 = 0xffff
|
||||
const ZIP64_MARKER_32 = 0xffffffff
|
||||
|
||||
/**
|
||||
* Extract a zip archive buffer into the target directory.
|
||||
|
|
@ -8,9 +16,15 @@ import { zipSync, unzipSync } from 'fflate'
|
|||
*/
|
||||
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
const files = unzipSync(new Uint8Array(buffer))
|
||||
for (const [name, data] of Object.entries(files)) {
|
||||
const filePath = safeJoin(targetDir, name)
|
||||
const archive = new Uint8Array(buffer)
|
||||
validateZipCentralDirectory(archive)
|
||||
const files = unzipSync(archive)
|
||||
const entries = Object.entries(files).map(([name, data]) => ({
|
||||
name,
|
||||
data,
|
||||
filePath: safeJoin(targetDir, name),
|
||||
}))
|
||||
for (const { name, data, filePath } of entries) {
|
||||
if (name.endsWith('/')) {
|
||||
await mkdir(filePath, { recursive: true })
|
||||
continue
|
||||
|
|
@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
|
|||
}
|
||||
}
|
||||
|
||||
function validateZipCentralDirectory(archive: Uint8Array): void {
|
||||
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
|
||||
const eocdOffset = findEndOfCentralDirectory(view)
|
||||
if (eocdOffset < 0) {
|
||||
throw new Error('invalid zip central directory')
|
||||
}
|
||||
|
||||
const diskNumber = view.getUint16(eocdOffset + 4, true)
|
||||
const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true)
|
||||
const entriesOnDisk = view.getUint16(eocdOffset + 8, true)
|
||||
const totalEntries = view.getUint16(eocdOffset + 10, true)
|
||||
const centralDirectorySize = view.getUint32(eocdOffset + 12, true)
|
||||
const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true)
|
||||
|
||||
if (
|
||||
entriesOnDisk === ZIP64_MARKER_16 ||
|
||||
totalEntries === ZIP64_MARKER_16 ||
|
||||
centralDirectorySize === ZIP64_MARKER_32 ||
|
||||
centralDirectoryOffset === ZIP64_MARKER_32
|
||||
) {
|
||||
throw new Error('zip64 archives are not supported')
|
||||
}
|
||||
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) {
|
||||
throw new Error('multi-disk zip archives are not supported')
|
||||
}
|
||||
if (totalEntries > MAX_ZIP_ENTRIES) {
|
||||
throw new Error('zip entry count exceeds limit')
|
||||
}
|
||||
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
|
||||
throw new Error('invalid zip central directory')
|
||||
}
|
||||
|
||||
let offset = centralDirectoryOffset
|
||||
let totalUncompressedSize = 0
|
||||
const decoder = new TextDecoder()
|
||||
for (let i = 0; i < totalEntries; i++) {
|
||||
if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
|
||||
throw new Error('invalid zip central directory')
|
||||
}
|
||||
const uncompressedSize = view.getUint32(offset + 24, true)
|
||||
const nameLength = view.getUint16(offset + 28, true)
|
||||
const extraLength = view.getUint16(offset + 30, true)
|
||||
const commentLength = view.getUint16(offset + 32, true)
|
||||
const nameStart = offset + 46
|
||||
const nameEnd = nameStart + nameLength
|
||||
const nextOffset = nameEnd + extraLength + commentLength
|
||||
if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) {
|
||||
throw new Error('invalid zip central directory')
|
||||
}
|
||||
|
||||
const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
|
||||
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
|
||||
throw new Error('zip entry size exceeds limit')
|
||||
}
|
||||
totalUncompressedSize += uncompressedSize
|
||||
if (totalUncompressedSize > MAX_PACKAGE_BYTES) {
|
||||
throw new Error('zip total uncompressed size exceeds limit')
|
||||
}
|
||||
offset = nextOffset
|
||||
}
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(view: DataView): number {
|
||||
const minOffset = Math.max(0, view.byteLength - 0xffff - 22)
|
||||
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
|
||||
if (view.getUint32(offset, true) === EOCD_SIGNATURE) {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a zip archive from a directory.
|
||||
* Returns the archive as a Blob.
|
||||
|
|
|
|||
56
cli/src/platform/download.ts
Normal file
56
cli/src/platform/download.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { EXIT } from '../shared/constants'
|
||||
import { CliError } from '../shared/errors'
|
||||
|
||||
export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024
|
||||
|
||||
export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise<ArrayBuffer> {
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength !== null) {
|
||||
const declaredLength = Number(contentLength)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new CliError('download exceeds maximum package size', EXIT.network, {
|
||||
contentLength: declaredLength,
|
||||
maxBytes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
throw new CliError('download exceeds maximum package size', EXIT.network, {
|
||||
receivedBytes: buffer.byteLength,
|
||||
maxBytes
|
||||
})
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let receivedBytes = 0
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
receivedBytes += value.byteLength
|
||||
if (receivedBytes > maxBytes) {
|
||||
await reader.cancel()
|
||||
throw new CliError('download exceeds maximum package size', EXIT.network, {
|
||||
receivedBytes,
|
||||
maxBytes
|
||||
})
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
const result = new Uint8Array(receivedBytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return result.buffer
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { SkillHubClient } from '../clients/skillhub-client'
|
||||
import { InventoryStore } from '../stores/inventory-store'
|
||||
import { CliError } from '../shared/errors'
|
||||
import { EXIT } from '../shared/constants'
|
||||
import { extractZip } from '../platform/archive'
|
||||
import { pathExists } from '../platform/paths'
|
||||
import { readBoundedResponseBody } from '../platform/download'
|
||||
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
|
||||
import type { AgentCandidate } from '../agents/types'
|
||||
|
||||
export interface InstallOptions {
|
||||
|
|
@ -19,54 +20,105 @@ export interface InstallOptions {
|
|||
home?: string | undefined
|
||||
}
|
||||
|
||||
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
|
||||
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)
|
||||
const buffer = await response.arrayBuffer()
|
||||
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 }> = []
|
||||
|
||||
const installed: Array<{ agent: string; dir: string }> = []
|
||||
const store = new InventoryStore(options.home)
|
||||
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)
|
||||
|
||||
for (const target of options.targets) {
|
||||
const skillDir = join(target.rootDir, options.slug)
|
||||
|
||||
if (await pathExists(skillDir) && !options.force) {
|
||||
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 })
|
||||
}
|
||||
|
||||
if (await pathExists(skillDir) && options.force) {
|
||||
await store.removeTargetsByInstallDir(skillDir)
|
||||
await rm(skillDir, { recursive: true, force: true })
|
||||
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)
|
||||
const buffer = await readBoundedResponseBody(response)
|
||||
|
||||
const installed: Array<{ agent: string; dir: string }> = []
|
||||
const store = new InventoryStore(options.home)
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
await extractZip(buffer, tempDir)
|
||||
|
||||
const installedAt = new Date().toISOString()
|
||||
const metaDir = join(tempDir, '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
registry: options.registry,
|
||||
namespace: options.namespace,
|
||||
slug: options.slug,
|
||||
version: resolved.version,
|
||||
agent: target.agent,
|
||||
installedAt
|
||||
}, null, 2))
|
||||
|
||||
if (await pathExists(skillDir) && !options.force) {
|
||||
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
|
||||
path: skillDir,
|
||||
next: 'pass --force to overwrite'
|
||||
})
|
||||
}
|
||||
|
||||
if (await pathExists(skillDir) && options.force) {
|
||||
await store.removeTargetsByInstallDir(skillDir)
|
||||
await rm(skillDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
try {
|
||||
await rename(tempDir, skillDir)
|
||||
} catch (error) {
|
||||
if (!options.force && await pathExists(skillDir)) {
|
||||
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
|
||||
path: skillDir,
|
||||
next: 'pass --force to overwrite'
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
})
|
||||
} finally {
|
||||
if (!movedIntoPlace) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Create skill directory and extract into a clean skill-specific directory.
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await extractZip(buffer, skillDir)
|
||||
|
||||
// Write .skillhub/metadata.json
|
||||
const metaDir = join(skillDir, '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
registry: options.registry,
|
||||
namespace: options.namespace,
|
||||
slug: options.slug,
|
||||
version: resolved.version,
|
||||
agent: target.agent,
|
||||
installedAt: new Date().toISOString()
|
||||
}, null, 2))
|
||||
|
||||
// Update inventory
|
||||
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
installed.push({ agent: target.agent, dir: skillDir })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export const EXIT = {
|
|||
auth: 2,
|
||||
network: 3,
|
||||
filesystem: 4,
|
||||
usage: 5
|
||||
usage: 5,
|
||||
validation: 6
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
27
cli/src/shared/skill-name-parser.ts
Normal file
27
cli/src/shared/skill-name-parser.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface ParsedSkillName {
|
||||
namespace: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
|
||||
const separatorIndex = skillName.indexOf('--')
|
||||
|
||||
if (separatorIndex <= 0) {
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
slug: separatorIndex === 0 ? skillName.slice(2) : skillName
|
||||
}
|
||||
}
|
||||
|
||||
if (separatorIndex === skillName.length - 2) {
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
slug: skillName.slice(0, -2)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: skillName.slice(0, separatorIndex),
|
||||
slug: skillName.slice(separatorIndex + 2)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,16 +22,23 @@ 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 with a standard SkillHub error envelope
|
||||
* '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' | 'not_found' | 'server_error' | 'network'
|
||||
export type FailureMode = 'auth' | 'forbidden' | '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,
|
||||
msg: 'API token is missing required scope: skill:publish',
|
||||
requestId: 'req-test-forbidden'
|
||||
}, { status: 403 })
|
||||
case 'not_found':
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
case 'server_error':
|
||||
|
|
@ -91,6 +98,12 @@ export interface CapturedPublish {
|
|||
visibility: string
|
||||
}
|
||||
|
||||
export interface CapturedValidate {
|
||||
namespace: string
|
||||
fileName: string
|
||||
visibility: string
|
||||
}
|
||||
|
||||
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
|
||||
export interface CapturedResolve {
|
||||
namespace: string
|
||||
|
|
@ -116,6 +129,8 @@ interface FakeRegistryOptions {
|
|||
searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }>
|
||||
/** Skills available for resolve / download / delete / publish. */
|
||||
skills?: FakeSkill[]
|
||||
/** Response to return for publish/validate (dry-run) requests. */
|
||||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -128,6 +143,7 @@ interface FakeRegistryOptions {
|
|||
download?: FailureMode
|
||||
deleteRemote?: FailureMode
|
||||
publish?: FailureMode
|
||||
validate?: FailureMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +183,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
publish: CapturedPublish | null
|
||||
resolve: CapturedResolve | null
|
||||
delete: CapturedDelete | null
|
||||
} = { publish: null, resolve: null, delete: null }
|
||||
validate: CapturedValidate | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null }
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
// TCP-level failure. Start a connection-dropping server and return its URL
|
||||
|
|
@ -339,6 +356,34 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
// Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate
|
||||
const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/)
|
||||
if (validateMatch && req.method === 'POST') {
|
||||
if (options.failures?.validate) return failureResponse(options.failures.validate)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
const namespace = validateMatch[1]!
|
||||
|
||||
return req.formData().then(form => {
|
||||
const fileField = form.get('file')
|
||||
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
|
||||
let fileName = 'skill.zip'
|
||||
if (fileField instanceof File) {
|
||||
fileName = fileField.name || fileName
|
||||
}
|
||||
state.validate = { namespace, fileName, visibility }
|
||||
|
||||
const dryRunData = options.dryRunResponse ?? {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
resolvedSlug: fileName.replace(/\.zip$/, ''),
|
||||
resolvedVersion: '1.0.0'
|
||||
}
|
||||
return Response.json({ code: 0, data: dryRunData })
|
||||
})
|
||||
}
|
||||
|
||||
// Publish: POST /api/cli/v1/skills/:namespace/publish
|
||||
const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/)
|
||||
if (publishMatch && req.method === 'POST') {
|
||||
|
|
|
|||
159
cli/test/integration/auth-resolution.test.ts
Normal file
159
cli/test/integration/auth-resolution.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* End-to-end integration coverage for token / registry priority resolution.
|
||||
*
|
||||
* The unit test in test/unit/services/registry-service.test.ts pins the
|
||||
* resolution function in isolation. These tests verify the same priorities
|
||||
* are wired through the actual CLI subprocess: --flag > SKILLHUB_* env >
|
||||
* stored config / credentials > built-in default.
|
||||
*
|
||||
* Why this matters: a regression in the wiring (e.g. command forgets to
|
||||
* forward `process.env`) would silently downgrade users to the wrong
|
||||
* registry / token without surfacing in unit tests.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
registryB?.stop(); registryB = undefined
|
||||
})
|
||||
|
||||
async function seedCredentials(home: string, registryUrl: string, token: string): Promise<void> {
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'credentials.json'),
|
||||
JSON.stringify({ tokens: { [registryUrl]: token } })
|
||||
)
|
||||
}
|
||||
|
||||
async function seedConfig(home: string, registryUrl: string): Promise<void> {
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'config.json'),
|
||||
JSON.stringify({ registry: registryUrl })
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token priority: --token > SKILLHUB_TOKEN > stored
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('auth resolution — token priority', () => {
|
||||
test('--token flag wins over SKILLHUB_TOKEN env', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_flag',
|
||||
user: { handle: 'flag-user', displayName: 'Flag' }
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url, '--token', 'sk_from_flag'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_wrong_from_env' }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('flag-user')
|
||||
})
|
||||
|
||||
test('SKILLHUB_TOKEN env wins over stored token', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_env',
|
||||
user: { handle: 'env-user', displayName: 'Env' }
|
||||
})
|
||||
await seedCredentials(env.home, registry.url, 'sk_wrong_from_storage')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_from_env' }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('env-user')
|
||||
})
|
||||
|
||||
test('stored token used when neither --token nor env is set', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_storage',
|
||||
user: { handle: 'storage-user', displayName: 'Storage' }
|
||||
})
|
||||
await seedCredentials(env.home, registry.url, 'sk_from_storage')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('storage-user')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry priority: --registry > SKILLHUB_REGISTRY > config.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('auth resolution — registry priority', () => {
|
||||
test('--registry flag wins over SKILLHUB_REGISTRY env', async () => {
|
||||
const env = await createTempHome()
|
||||
// Each registry only authenticates its own token. The wrong registry
|
||||
// would 401, so a successful whoami proves the right one was used.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_a',
|
||||
user: { handle: 'a-user', displayName: 'A' }
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_b',
|
||||
user: { handle: 'b-user', displayName: 'B' }
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url, '--token', 'sk_a'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registryB.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('a-user')
|
||||
})
|
||||
|
||||
test('SKILLHUB_REGISTRY env wins over config.registry', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_env',
|
||||
user: { handle: 'env-reg', displayName: 'EnvReg' }
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_config',
|
||||
user: { handle: 'config-reg', displayName: 'ConfigReg' }
|
||||
})
|
||||
await seedConfig(env.home, registryB.url)
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--token', 'sk_env'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registry.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('env-reg')
|
||||
})
|
||||
|
||||
test('config.registry used when no --registry / env present', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_config',
|
||||
user: { handle: 'config-only-user', displayName: 'CfgOnly' }
|
||||
})
|
||||
await seedConfig(env.home, registry.url)
|
||||
await seedCredentials(env.home, registry.url, 'sk_config')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('config-only-user')
|
||||
})
|
||||
})
|
||||
164
cli/test/integration/concurrency.test.ts
Normal file
164
cli/test/integration/concurrency.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Concurrency tests for inventory.json bookkeeping.
|
||||
*
|
||||
* inventory-store.ts uses an OS-level lock file with retry + stale-lock
|
||||
* detection. These tests exercise that path through real CLI subprocesses
|
||||
* (Bun.spawn) running in parallel — the same way users hit it when scripts
|
||||
* fan out installs.
|
||||
*
|
||||
* The unit test in test/unit/stores/inventory-store.test.ts pins the
|
||||
* single-process lock recovery; here we cover the cross-process case.
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# c') })
|
||||
}
|
||||
|
||||
describe('cross-process concurrency on inventory.json', () => {
|
||||
// KNOWN BUG (documented here, not yet fixed):
|
||||
// inventory-store.upsertTarget() reads inventory, modifies in memory,
|
||||
// then writeAtomic() acquires the lock only over the write half. Two
|
||||
// concurrent installs each read the (empty) inventory, each adds their
|
||||
// own item, and the second writer overwrites the first — a classic
|
||||
// lost-update.
|
||||
//
|
||||
// When the fix lands (lock spans read+write, or upsertTarget acquires
|
||||
// the lock first and re-reads), tighten the inventory assertion to
|
||||
// `expect(slugs).toEqual(['first', 'second'])`.
|
||||
test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'first', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'second', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const dirA = join(env.cwd, 'A')
|
||||
const dirB = join(env.cwd, 'B')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
runCli(
|
||||
['install', 'first', '--dir', dirA, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
),
|
||||
runCli(
|
||||
['install', 'second', '--dir', dirB, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
])
|
||||
|
||||
// Both subprocess installs report success — neither errored at the
|
||||
// protocol level even though the inventory bookkeeping race ate one of
|
||||
// their inventory writes.
|
||||
expect(r1.exitCode).toBe(0)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
// Filesystem is correct: both bundles extracted independently.
|
||||
expect(await Bun.file(join(dirA, 'first', 'SKILL.md')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(dirB, 'second', 'SKILL.md')).exists()).toBe(true)
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
const slugs = inv.items.map(i => i.slug).sort()
|
||||
// Today: at least one slug always lands; under the lost-update race
|
||||
// both may NOT be there. When the lock widens to cover read+write,
|
||||
// upgrade this to `toEqual(['first', 'second'])`.
|
||||
expect(slugs.length).toBeGreaterThanOrEqual(1)
|
||||
const lastSlug = slugs[slugs.length - 1]!
|
||||
expect(['first', 'second']).toContain(lastSlug)
|
||||
})
|
||||
|
||||
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'race', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'race-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
runCli(
|
||||
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
),
|
||||
runCli(
|
||||
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
])
|
||||
|
||||
// Two valid outcomes: (a) both succeed because the loser's existence
|
||||
// check ran BEFORE the winner extracted, OR (b) one succeeds and the
|
||||
// other reports already-installed (EXIT.filesystem).
|
||||
// Either way, inventory must end up coherent (single item, single
|
||||
// target — no duplicates).
|
||||
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
|
||||
expect(codes[0]).toBe(0) // at least one succeeded
|
||||
const otherCode = codes[1]!
|
||||
expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
const item = inv.items.find(i => i.slug === 'race')
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.targets).toHaveLength(1) // no duplicate targets
|
||||
})
|
||||
|
||||
test('install proceeds after a stale lock file from a dead process', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'after-stale', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Plant a stale lock file: PID 1 (init, never the same as our test
|
||||
// child, and won't match the spawned subprocess's PID), with a very
|
||||
// old timestamp so the store treats it as stale.
|
||||
const skillhubDir = join(env.home, '.skillhub')
|
||||
await mkdir(skillhubDir, { recursive: true })
|
||||
const lockPath = join(skillhubDir, 'inventory.json.lock')
|
||||
const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold
|
||||
await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp }))
|
||||
|
||||
const installDir = join(env.cwd, 'stale')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'after-stale', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(skillhubDir, 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined()
|
||||
})
|
||||
})
|
||||
509
cli/test/integration/cross-command.test.ts
Normal file
509
cli/test/integration/cross-command.test.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* Cross-command flow tests.
|
||||
*
|
||||
* Per-command tests verify each subcommand in isolation. These cases pin
|
||||
* behaviors that only emerge when commands chain — e.g. "logout then install
|
||||
* fails with auth" or "install + fs-delete + list reports status=missing".
|
||||
* Bugs in the boundaries between commands (shared inventory, credentials,
|
||||
* config) tend to slip through single-command suites.
|
||||
*/
|
||||
import { mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
registryB?.stop(); registryB = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# x-cross') })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Auth lifecycle: login → whoami → logout → whoami
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — auth lifecycle', () => {
|
||||
test('login → whoami(success) → logout → whoami(not logged in)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'cycle-user', displayName: 'Cycle' }
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const w1 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(w1.exitCode).toBe(0)
|
||||
expect(w1.stdout).toContain('cycle-user')
|
||||
|
||||
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
const w2 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(w2.exitCode).toBe(2)
|
||||
expect(w2.stderr.toLowerCase()).toContain('not logged in')
|
||||
})
|
||||
|
||||
test('logout-then-install against an auth-required registry fails with EXIT.auth', async () => {
|
||||
const env = await createTempHome()
|
||||
// Inject auth failure on resolve so this fake server behaves like a
|
||||
// production registry that requires a bearer token even on resolve.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
failures: { resolve: 'auth' }
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'after-logout')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
// No --token here — credentials were just cleared by logout.
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(2) // EXIT.auth
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Full local lifecycle: install → list → remove → list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — local lifecycle', () => {
|
||||
test('install → list → remove --all → list shows empty', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'lifecycle')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list1 = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(JSON.parse(list1.stdout).items).toHaveLength(1)
|
||||
|
||||
await runCli(
|
||||
['remove', 'pdf-parser', '--all', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list2 = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(JSON.parse(list2.stdout).items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('install x2 same slug + same dir without --force conflicts; --force succeeds; second install replaces first', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'reinstall-here')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(4) // EXIT.filesystem (already installed)
|
||||
|
||||
const r3 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r3.exitCode).toBe(0)
|
||||
|
||||
// Inventory has exactly one target, not two duplicates.
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
const item = inv.items.find(i => i.slug === 'pdf-parser')
|
||||
expect(item?.targets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('install A then install B (different slugs, same parent dir) → list shows both', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'a-skill', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'b-skill', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'two-skills')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(
|
||||
['install', 'a-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
await runCli(
|
||||
['install', 'b-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string }>
|
||||
expect(items.map(i => i.slug).sort()).toEqual(['a-skill', 'b-skill'])
|
||||
})
|
||||
|
||||
test('remove --all → install same slug again succeeds (no stale inventory state)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'reuse')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['remove', 'pdf-parser', '--all', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Re-install at the same dir without --force should now succeed, since
|
||||
// the previous install was removed.
|
||||
const reinstall = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(reinstall.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Filesystem drift between install dir and inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — filesystem drift', () => {
|
||||
test('install → fs-delete the install dir → list reports status=missing', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'drift')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// External clobber: delete the install dir behind the CLI's back.
|
||||
await rm(join(installDir, 'pdf-parser'), { recursive: true, force: true })
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items[0]?.slug).toBe('pdf-parser')
|
||||
expect(items[0]?.status).toBe('missing')
|
||||
})
|
||||
|
||||
// After commit a14d89d8 ("refactor(cli): improve doctor command
|
||||
// semantics and transparency") doctor switched from REPLACE to MERGE
|
||||
// semantics: it never removes inventory entries, even when the install
|
||||
// dir on disk is gone. Stale entries are surfaced via `list --json`'s
|
||||
// status="missing" instead. This test pins that contract.
|
||||
test('install → fs-delete the install dir → doctor preserves the entry; list reports status=missing', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Install into an agent-shaped dir under cwd so doctor will scan it.
|
||||
const codexSkills = join(env.cwd, '.codex', 'skills')
|
||||
await mkdir(codexSkills, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', codexSkills, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Wipe the install but leave the dir tree shape — metadata gone.
|
||||
await rm(join(codexSkills, 'pdf-parser'), { recursive: true, force: true })
|
||||
|
||||
const doctor = await runCli(['doctor', '--json'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
expect(doctor.exitCode).toBe(0)
|
||||
|
||||
// Inventory still has the entry — doctor preserved it because the
|
||||
// installDir was NOT in the (now-empty) scan result.
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inv.items.find(i => i.slug === 'pdf-parser')).toBeDefined()
|
||||
|
||||
// The user-facing surface for "this is gone on disk" is `list` — it
|
||||
// reports status="missing" by stat'ing the installDir at read time.
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items.find(i => i.slug === 'pdf-parser')?.status).toBe('missing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. doctor idempotence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — doctor idempotence', () => {
|
||||
test('two consecutive doctor runs produce identical inventory (idempotent)', async () => {
|
||||
const env = await createTempHome()
|
||||
|
||||
// Seed one valid metadata file.
|
||||
const metaDir = join(env.cwd, '.codex', 'skills', 'pdf-parser', '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
const after1 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
|
||||
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
const after2 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
|
||||
expect(after2).toBe(after1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. publish does not change local inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — publish vs local inventory', () => {
|
||||
test('publish does NOT add the published skill to local inventory', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u', displayName: 'U' } })
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Build a tiny skill dir to publish.
|
||||
const dir = join(env.cwd, 'src-skill')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), '---\nname: pub-only\ndescription: x\n---\n# pub-only')
|
||||
|
||||
const pub = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(pub.exitCode).toBe(0)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(JSON.parse(list.stdout).items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Cross-registry isolation in queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — cross-registry isolation', () => {
|
||||
test('list scoped to registry A does not show items installed from registry B', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_a',
|
||||
user: { handle: 'a', displayName: 'A' },
|
||||
skills: [{ namespace: 'global', slug: 'a-only', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_b',
|
||||
user: { handle: 'b', displayName: 'B' },
|
||||
skills: [{ namespace: 'global', slug: 'b-only', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const dirA = join(env.cwd, 'A')
|
||||
const dirB = join(env.cwd, 'B')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
await runCli(['install', 'a-only', '--dir', dirA, '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['install', 'b-only', '--dir', dirB, '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const listA = await runCli(['list', '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const slugsA = (JSON.parse(listA.stdout).items as Array<{ slug: string }>).map(i => i.slug)
|
||||
expect(slugsA).toEqual(['a-only'])
|
||||
|
||||
const listB = await runCli(['list', '--registry', registryB.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const slugsB = (JSON.parse(listB.stdout).items as Array<{ slug: string }>).map(i => i.slug)
|
||||
expect(slugsB).toEqual(['b-only'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Auto-detect + list filter integration (project-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — auto-detect + list', () => {
|
||||
test('install auto-detects project-level .codex; subsequent list --agent codex shows it', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Pre-create .codex/skills so auto-detect picks codex/project-level.
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
|
||||
const inst = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(inst.exitCode).toBe(0)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--agent', 'codex', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; agent: string }>
|
||||
expect(items.some(i => i.slug === 'pdf-parser' && i.agent === 'codex')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Inventory metadata corruption resilience after install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — metadata.json drift', () => {
|
||||
test('install → manually corrupt metadata.json → list reports the row but with sane handling', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'meta-drift')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Corrupt the installed metadata. inventory.json (the authoritative
|
||||
// source for `list`) is untouched, so `list` should still work.
|
||||
await writeFile(
|
||||
join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'),
|
||||
'{ truncated'
|
||||
)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items[0]?.slug).toBe('pdf-parser')
|
||||
// Status remains "ok" because list uses inventory.json, not metadata.json.
|
||||
expect(items[0]?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Registry priority chain end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — registry priority end-to-end', () => {
|
||||
test('search uses --registry over SKILLHUB_REGISTRY env over default', async () => {
|
||||
registry = await startFakeRegistry({
|
||||
searchItems: [{ namespace: 'global', slug: 'wins', latestVersion: '1.0.0', summary: 'right one' }]
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
searchItems: [{ namespace: 'global', slug: 'loses', latestVersion: '1.0.0', summary: 'wrong one' }]
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['search', '', '--registry', registry.url, '--json'],
|
||||
{ SKILLHUB_REGISTRY: registryB.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const items = JSON.parse(result.stdout).items as Array<{ slug: string }>
|
||||
expect(items.map(i => i.slug)).toEqual(['wins'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Help / Version ergonomics across commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — help reaches every documented command', () => {
|
||||
test('every command listed in help responds to --help with non-empty body', async () => {
|
||||
const helpResult = await runCli(['help'])
|
||||
expect(helpResult.exitCode).toBe(0)
|
||||
|
||||
const commandNames = [
|
||||
'help', 'version', 'login', 'logout', 'whoami',
|
||||
'search', 'install', 'list', 'remove', 'doctor',
|
||||
'publish', 'update'
|
||||
]
|
||||
for (const cmd of commandNames) {
|
||||
expect(helpResult.stdout).toContain(cmd)
|
||||
const sub = await runCli([cmd, '--help'])
|
||||
// --help exits 0 for cac-style CLIs; we don't insist on that, just
|
||||
// that some informative output makes it to stdout.
|
||||
expect(sub.stdout.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -143,6 +143,139 @@ describe('doctor command', () => {
|
|||
expect(json.inventoryPath).toContain('inventory.json')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: same registry+namespace+slug appearing in two agent dirs with
|
||||
// different versions surfaces in `conflicts` and is excluded from items.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor reports conflicts when two agent dirs disagree on version', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
// Two installs of the same global/pdf-parser with mismatched versions.
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.codex',
|
||||
slug: 'pdf-parser',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-20T12:00:00Z'
|
||||
}
|
||||
})
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.claude',
|
||||
slug: 'pdf-parser',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '2.0.0',
|
||||
agent: 'claude-code',
|
||||
installedAt: '2026-04-21T09:00:00Z'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home,
|
||||
USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
itemsScanned: number
|
||||
targetsScanned: number
|
||||
conflicts: Array<{ key: string; versions: string[] }>
|
||||
}
|
||||
expect(json.ok).toBe(true)
|
||||
// Conflicting group is dropped from items, recorded as a conflict.
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
expect(json.targetsScanned).toBe(0)
|
||||
expect(json.conflicts).toHaveLength(1)
|
||||
expect(json.conflicts[0]?.key).toBe('https://skill.xfyun.cn|global|pdf-parser')
|
||||
expect(json.conflicts[0]?.versions.sort()).toEqual(['1.0.0', '2.0.0'])
|
||||
|
||||
// The persisted inventory must mirror the JSON output: no items.
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: unknown[] }
|
||||
expect(inventory.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: malformed metadata (unparseable JSON, or missing required fields)
|
||||
// is reported in `skipped` and does not produce inventory entries. Two
|
||||
// distinct failure modes are seeded to exercise both branches in
|
||||
// scanMetadata: JSON.parse throw and the post-parse field check.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor reports skipped entries for malformed and incomplete metadata', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
// (1) Bad JSON: triggers the catch around JSON.parse → "no .skillhub/metadata.json"
|
||||
// because the catch block is shared with the readFile failure path.
|
||||
const badJsonDir = join(cwd, '.codex', 'skills', 'broken-json', '.skillhub')
|
||||
await mkdir(badJsonDir, { recursive: true })
|
||||
await writeFile(join(badJsonDir, 'metadata.json'), '{ this is not json')
|
||||
|
||||
// (2) Incomplete fields: parses fine but is missing `version`.
|
||||
const incompleteDir = join(cwd, '.claude', 'skills', 'incomplete', '.skillhub')
|
||||
await mkdir(incompleteDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(incompleteDir, 'metadata.json'),
|
||||
JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'incomplete',
|
||||
// version intentionally missing
|
||||
agent: 'claude-code',
|
||||
installedAt: '2026-04-22T10:00:00Z'
|
||||
})
|
||||
)
|
||||
|
||||
// (3) A valid sibling so we can prove skipped entries don't poison the
|
||||
// surrounding scan — the valid skill should still land in inventory.
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.codex',
|
||||
slug: 'good-skill',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'good-skill',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-22T10:00:00Z'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home,
|
||||
USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.ok).toBe(true)
|
||||
|
||||
// Both broken entries should be in skipped, the good one in items.
|
||||
const broken = json.skipped.find(s => s.path.endsWith('broken-json'))
|
||||
expect(broken).toBeDefined()
|
||||
const incomplete = json.skipped.find(s => s.path.endsWith('incomplete'))
|
||||
expect(incomplete).toBeDefined()
|
||||
expect(incomplete?.reason).toContain('incomplete')
|
||||
|
||||
expect(json.itemsScanned).toBe(1) // only good-skill
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inventory.items).toHaveLength(1)
|
||||
expect(inventory.items[0]?.slug).toBe('good-skill')
|
||||
})
|
||||
|
||||
test('doctor backs up existing inventory.json and reports backupPath', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
|
|
@ -227,4 +360,104 @@ describe('doctor command', () => {
|
|||
expect.arrayContaining(['external-skill', 'local-skill'])
|
||||
)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Symlink safety: doctor must skip (not follow) symlinked agent /
|
||||
// skill / .skillhub directories. This protects against malicious or
|
||||
// accidental symlinks that would otherwise let metadata be slurped from
|
||||
// arbitrary filesystem locations.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor skips an agent dir that is a symlink', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real target with a valid metadata file off in /tmp.
|
||||
const realRoot = join(cwd, '__real__', '.codex', 'skills', 'pdf-parser', '.skillhub')
|
||||
await mkdirP(realRoot, { recursive: true })
|
||||
await writeFile(join(realRoot, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
// Symlink ./.codex -> __real__/.codex inside cwd. Doctor scans cwd.
|
||||
await symlink(join(cwd, '__real__', '.codex'), join(cwd, '.codex'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
// The symlinked agent dir must NOT contribute an inventory item.
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
expect(json.skipped.some(s => s.path.endsWith('.codex') && s.reason.includes('regular directory'))).toBe(true)
|
||||
})
|
||||
|
||||
test('doctor skips a slug dir that is a symlink (real agent dir, symlinked slug)', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real metadata under cwd/__real__/pdf-parser/.skillhub/
|
||||
const realSlug = join(cwd, '__real__', 'pdf-parser')
|
||||
const realSkillhub = join(realSlug, '.skillhub')
|
||||
await mkdirP(realSkillhub, { recursive: true })
|
||||
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
// .codex/skills exists as a real dir, but pdf-parser inside it is a
|
||||
// symlink to the real metadata location.
|
||||
const skillsDir = join(cwd, '.codex', 'skills')
|
||||
await mkdirP(skillsDir, { recursive: true })
|
||||
await symlink(realSlug, join(skillsDir, 'pdf-parser'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
const symlinked = json.skipped.find(s => s.path.endsWith('pdf-parser'))
|
||||
expect(symlinked).toBeDefined()
|
||||
expect(symlinked?.reason).toContain('regular directory')
|
||||
})
|
||||
|
||||
test('doctor skips a .skillhub dir that is a symlink', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real metadata reachable through a symlinked .skillhub directory.
|
||||
const realSkillhub = join(cwd, '__real_meta__')
|
||||
await mkdirP(realSkillhub, { recursive: true })
|
||||
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
const slugDir = join(cwd, '.codex', 'skills', 'pdf-parser')
|
||||
await mkdirP(slugDir, { recursive: true })
|
||||
await symlink(realSkillhub, join(slugDir, '.skillhub'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
const skipped = json.skipped.find(s => s.path.endsWith('pdf-parser'))
|
||||
expect(skipped).toBeDefined()
|
||||
expect(skipped?.reason.toLowerCase()).toMatch(/skillhub|regular directory/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -272,3 +331,691 @@ describe('install command — P1', () => {
|
|||
// test/unit/agents/resolver.test.ts.
|
||||
// -------------------------------------------------------------------------
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0/P1 — Conflict & --force handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — conflict and --force', () => {
|
||||
test('re-installing without --force into an existing dir errors with EXIT.filesystem', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-conflict')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const first = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(first.exitCode).toBe(0)
|
||||
|
||||
const second = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(second.exitCode).toBe(4) // EXIT.filesystem
|
||||
expect(second.stderr).toContain('already installed')
|
||||
expect(second.stderr).toContain('--force')
|
||||
})
|
||||
|
||||
test('--force overwrites stale files left in the install dir', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-force')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Tamper with SKILL.md to prove the second install replaces it.
|
||||
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
|
||||
await writeFile(skillFile, '# tampered content')
|
||||
expect(await readFile(skillFile, 'utf-8')).toBe('# tampered content')
|
||||
|
||||
const forced = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(forced.exitCode).toBe(0)
|
||||
expect(await readFile(skillFile, 'utf-8')).toBe('# test skill')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Server-side error mapping during install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — server errors', () => {
|
||||
test('resolve 404 surfaces an error and aborts install (no metadata.json written)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
failures: { resolve: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-resolve-404')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/404|not found/i)
|
||||
|
||||
// No metadata file should have been created at the install destination.
|
||||
const metaPath = join(installDir, 'no-such-slug', '.skillhub', 'metadata.json')
|
||||
expect(await Bun.file(metaPath).exists()).toBe(false)
|
||||
})
|
||||
|
||||
// Regression test for the production bug observed on 2026-05-06: server
|
||||
// marks `bundle_ready=true` in DB but the bundle file is missing on disk.
|
||||
// /resolve returns 200 with a downloadUrl, then /download returns 404. The
|
||||
// CLI must surface a non-zero exit and a meaningful stderr — not silently
|
||||
// succeed with an empty install dir.
|
||||
test('download 404 (resolve OK) is reported as a download failure', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
// resolve succeeds (skill is present in fixture list) but the download
|
||||
// endpoint is forced to 404 to simulate a missing bundle on storage.
|
||||
skills: [{ namespace: 'global', slug: 'orphan-bundle', version: '1.0.0', zipBytes: makeSkillZip() }],
|
||||
failures: { download: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-bundle-missing')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'orphan-bundle', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/download|404|not found/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Path safety: install only writes inside <dir>/<slug>/
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('install only writes inside <dir>/<slug>/ — sibling files in <dir> are untouched', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'shared-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
// Place an unrelated file as a sibling of the future <slug>/ subdir.
|
||||
const sibling = join(installDir, 'IMPORTANT.txt')
|
||||
await writeFile(sibling, 'this file must survive install')
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
// Sibling file must still exist with original content.
|
||||
expect(await readFile(sibling, 'utf-8')).toBe('this file must survive install')
|
||||
// <slug>/ subdir created.
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('--force re-install does not touch sibling files in <dir>', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'shared-force')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// After first install, drop a sibling file; --force should not delete it.
|
||||
const sibling = join(installDir, 'sibling-after-install.bin')
|
||||
await writeFile(sibling, 'sentinel')
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
expect(await readFile(sibling, 'utf-8')).toBe('sentinel')
|
||||
})
|
||||
|
||||
test('install --dir creates the <slug> subdir even when <dir> is empty', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'empty-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('install --dir pointing at a regular file (not a directory) fails before download', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Create a file at the location --dir would otherwise treat as a directory.
|
||||
const filePath = join(env.cwd, 'not-a-dir')
|
||||
await writeFile(filePath, 'i am a file, not a dir')
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', filePath, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// Original file must still be unchanged (the install should not have
|
||||
// scribbled on it before bailing).
|
||||
expect(await readFile(filePath, 'utf-8')).toBe('i am a file, not a dir')
|
||||
})
|
||||
|
||||
test('--json emits a parseable error envelope when install fails', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
failures: { resolve: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-json-error')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// JSON error envelope is printed to stdout (or stderr, depending on the
|
||||
// command); we accept either to keep the test resilient to that choice.
|
||||
const candidate = result.stdout || result.stderr
|
||||
const json = JSON.parse(candidate) as {
|
||||
ok: boolean
|
||||
message: string
|
||||
exitCode: number
|
||||
}
|
||||
expect(json.ok).toBe(false)
|
||||
expect(typeof json.message).toBe('string')
|
||||
expect(json.exitCode).toBe(result.exitCode)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Multi-agent and auto-detect targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — multi-agent & auto-detect', () => {
|
||||
test('multi --agent installs the same skill into every specified user-level dir', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'codex',
|
||||
'--agent', 'claude-code',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
|
||||
const agents = parsed.installed.map(t => t.agent).sort()
|
||||
expect(agents).toEqual(['claude-code', 'codex'])
|
||||
|
||||
// Both metadata files exist on disk under user-level <home>/.<agent>/skills.
|
||||
expect(await Bun.file(join(env.home, '.codex', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(env.home, '.claude', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('duplicate --agent dedupes to one target', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'codex',
|
||||
'--agent', 'codex',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
|
||||
expect(parsed.installed).toHaveLength(1)
|
||||
expect(parsed.installed[0]?.agent).toBe('codex')
|
||||
})
|
||||
|
||||
test('--agent unknown-id surfaces a usage error with hint', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--agent', 'totally-not-a-real-agent', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(5) // EXIT.usage
|
||||
expect(result.stderr.toLowerCase()).toMatch(/unknown agent|--dir/)
|
||||
})
|
||||
|
||||
test('auto-detect: cwd with only .codex/skills present installs project-level there', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Pre-create the codex skills dir so auto-detect picks project scope.
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
|
||||
expect(parsed.installed[0]?.agent).toBe('codex')
|
||||
// On macOS env.cwd may resolve through /private/var/... symlinks; assert
|
||||
// against the structural part of the path instead of an exact prefix.
|
||||
// Use a regex that accepts both Unix (/) and Windows (\) path separators.
|
||||
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.codex[/\\]skills[/\\]pdf-parser/)
|
||||
expect(parsed.installed[0]?.dir).not.toContain(env.home) // not user-level
|
||||
})
|
||||
|
||||
test('auto-detect: multiple agent dirs in cwd and non-interactive mode fails with hint', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
await mkdir(join(env.cwd, '.claude', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(5) // EXIT.usage
|
||||
expect(result.stderr.toLowerCase()).toMatch(/multiple install targets|--agent|--dir/)
|
||||
})
|
||||
|
||||
test('auto-detect: cwd with no agent dirs falls back to .agents/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
|
||||
expect(parsed.installed[0]?.agent).toBe('generic')
|
||||
expect(parsed.installed[0]?.dir).toContain('.agents')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Bundle integrity: download body that's not a valid zip
|
||||
// -------------------------------------------------------------------------
|
||||
test('download body that is not a valid zip surfaces an extraction error', async () => {
|
||||
const env = await createTempHome()
|
||||
// Stand up a custom server that returns valid resolve JSON but plain
|
||||
// text on download.
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
const baseUrl = `${url.protocol}//${url.host}`
|
||||
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
|
||||
if (resolveMatch && req.method === 'GET') {
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
namespace: resolveMatch[1],
|
||||
slug: resolveMatch[2],
|
||||
version: '1.0.0',
|
||||
versionId: 1,
|
||||
fingerprint: 'deadbeef',
|
||||
downloadUrl: `${baseUrl}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/versions/1.0.0/download`
|
||||
}
|
||||
})
|
||||
}
|
||||
if (url.pathname.includes('/download')) {
|
||||
// NOT a zip — plain text.
|
||||
return new Response('this is plain text, not a zip', {
|
||||
status: 200, headers: { 'Content-Type': 'application/zip' }
|
||||
})
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'bad-bundle')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// No metadata should have been written.
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2 — Slug edge cases (Unicode, very long)
|
||||
// -------------------------------------------------------------------------
|
||||
test('slug with non-ASCII characters round-trips through resolve URL (encoded)', async () => {
|
||||
const env = await createTempHome()
|
||||
let resolveUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.includes('/resolve')) {
|
||||
resolveUrl = req.url
|
||||
// Return 404 — we only care that the URL was constructed correctly.
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'unicode-slug')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', '中文-技能', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
// Server returns 404 — install fails. Just confirm CLI didn't crash
|
||||
// before hitting the server.
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// The slug must appear URL-percent-encoded in the resolve URL.
|
||||
expect(resolveUrl).toMatch(/%E4%B8%AD%E6%96%87/)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('slug 200+ characters is forwarded as-is to /resolve (server is authoritative)', async () => {
|
||||
const env = await createTempHome()
|
||||
let resolveUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.includes('/resolve')) {
|
||||
resolveUrl = req.url
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'long-slug')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const longSlug = 'a'.repeat(220)
|
||||
const result = await runCli(
|
||||
['install', longSlug, '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(resolveUrl).toContain(longSlug)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0 — --scope flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — --scope', () => {
|
||||
test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'project', '--agent', 'codex',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.cwd, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope user --agent codex installs to <home>/.codex/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'user', '--agent', 'codex',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.home, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope user clean env falls back to <home>/.agents/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'user',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.home, '.agents', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope project --agent codex --json output omits scope field on installed entries', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'project', '--agent', 'codex', '--json',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
expect(parsed).toMatchObject({ ok: true, namespace: 'global', slug: 'foo' })
|
||||
expect(parsed.installed[0]).toHaveProperty('agent')
|
||||
expect(parsed.installed[0]).toHaveProperty('dir')
|
||||
expect(parsed.installed[0]).not.toHaveProperty('scope')
|
||||
})
|
||||
|
||||
test('--scope invalid returns exit code 5 with usage error', async () => {
|
||||
const result = await runCli(['install', 'foo', '--scope', 'invalid'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
expect(result.stderr).toMatch(/user.+project|"user".+"project"/)
|
||||
})
|
||||
|
||||
test('--scope invalid --json returns JSON error shape', async () => {
|
||||
const result = await runCli(['install', 'foo', '--scope', 'invalid', '--json'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
const parsed = JSON.parse(result.stderr)
|
||||
expect(parsed.ok).toBe(false)
|
||||
expect(parsed.exitCode).toBe(5)
|
||||
expect(parsed.message).toMatch(/user.+project/)
|
||||
})
|
||||
|
||||
test('--dir + --scope returns usage error', async () => {
|
||||
const result = await runCli(['install', 'foo', '--dir', '/tmp/x', '--scope', 'user'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
expect(result.stderr).toMatch(/--dir cannot be used with --scope/)
|
||||
})
|
||||
|
||||
test('--dir + --scope --json returns JSON usage error', async () => {
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--dir', '/tmp/x', '--scope', 'user', '--json']
|
||||
)
|
||||
expect(result.exitCode).toBe(5)
|
||||
const parsed = JSON.parse(result.stderr)
|
||||
expect(parsed.ok).toBe(false)
|
||||
expect(parsed.message).toMatch(/--dir cannot be used with --scope/)
|
||||
})
|
||||
|
||||
test('help install includes --scope usage and examples', async () => {
|
||||
const result = await runCli(['help', 'install'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toMatch(/--scope/)
|
||||
expect(result.stdout).toMatch(/--scope user/)
|
||||
expect(result.stdout).toMatch(/--scope project --agent codex/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
107
cli/test/integration/inventory-resilience.test.ts
Normal file
107
cli/test/integration/inventory-resilience.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* inventory.json resilience.
|
||||
*
|
||||
* inventory.json is the local manifest of installed skills. These tests pin
|
||||
* how the CLI behaves when that file is corrupt or written by overlapping
|
||||
* operations:
|
||||
* - list against a corrupt inventory should fail loudly (not silently)
|
||||
* - install against a corrupt inventory should still complete the
|
||||
* filesystem extraction even if inventory bookkeeping fails — partial
|
||||
* state surfaces a clear error
|
||||
* - sequential installs of distinct skills do not corrupt the manifest
|
||||
*
|
||||
* The unit test in test/unit/stores/inventory-store.test.ts asserts the
|
||||
* lock-file recovery path. These cover the user-facing CLI surface.
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# test') })
|
||||
}
|
||||
|
||||
describe('inventory resilience', () => {
|
||||
test('list exits non-zero when inventory.json is malformed (documents current generic-error UX)', async () => {
|
||||
const env = await createTempHome()
|
||||
await mkdir(join(env.home, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{ this is not JSON')
|
||||
|
||||
const result = await runCli(['list'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Contract: CLI must not crash silently or print a stack trace. It
|
||||
// exits non-zero and emits a short message.
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
expect(result.stderr.length).toBeLessThan(2000)
|
||||
// Documented gap: today's message is the generic "unexpected failure"
|
||||
// and does not mention `inventory` or `JSON`. When the CLI surfaces a
|
||||
// more specific message in the future, tighten this assertion.
|
||||
expect(result.stderr).toContain('Error')
|
||||
})
|
||||
|
||||
test('list --json on a corrupt inventory emits a parseable error envelope (not a stack trace)', async () => {
|
||||
const env = await createTempHome()
|
||||
await mkdir(join(env.home, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{"items":')
|
||||
|
||||
const result = await runCli(['list', '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
const candidate = result.stdout || result.stderr
|
||||
expect(candidate.length).toBeLessThan(2000)
|
||||
// Contract: --json error path is machine-parseable, regardless of the
|
||||
// (currently generic) human message.
|
||||
const json = JSON.parse(candidate) as { ok: boolean; message: string; exitCode: number }
|
||||
expect(json.ok).toBe(false)
|
||||
expect(typeof json.message).toBe('string')
|
||||
expect(json.exitCode).toBe(result.exitCode)
|
||||
})
|
||||
|
||||
test('two sequential installs of distinct slugs leave a coherent inventory', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'one', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'two', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
|
||||
const baseDir = join(env.cwd, 'pool')
|
||||
await mkdir(baseDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'one', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'two', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
|
||||
const slugs = inventory.items.map(i => i.slug).sort()
|
||||
expect(slugs).toEqual(['one', 'two'])
|
||||
for (const item of inventory.items) {
|
||||
expect(item.targets.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -351,4 +351,112 @@ describe('list command', () => {
|
|||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0].status).toBe('missing')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: Combined filters — --agent + --registry should narrow precisely
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent codex --registry A shows only codex targets from registry A', async () => {
|
||||
const { home } = await createTempHome()
|
||||
|
||||
const codexA = join(home, 'a', 'codex', 'pdf')
|
||||
const claudeA = join(home, 'a', 'claude', 'pdf')
|
||||
const codexB = join(home, 'b', 'codex', 'pdf')
|
||||
for (const d of [codexA, claudeA, codexB]) await mkdir(d, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: codexA, installedAt: INSTALLED_AT },
|
||||
{ agent: 'claude-code', rootDir: join(home, 'a', 'claude'), installDir: claudeA, installedAt: INSTALLED_AT }
|
||||
]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: codexB, installedAt: INSTALLED_AT }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['list', '--agent', 'codex', '--registry', FAKE_REGISTRY_A, '--json'],
|
||||
{ HOME: home, USERPROFILE: home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ agent: string; installDir: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.agent).toBe('codex')
|
||||
expect(json.items[0]?.installDir).toBe(codexA)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --agent + --dir should compose AND, not OR
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent + --dir composes as AND: only items matching both surface', async () => {
|
||||
const { home } = await createTempHome()
|
||||
|
||||
const codexHere = join(home, 'here', 'codex', 'pdf')
|
||||
const codexElse = join(home, 'else', 'codex', 'pdf')
|
||||
await mkdir(codexHere, { recursive: true })
|
||||
await mkdir(codexElse, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'here', 'codex'), installDir: codexHere, installedAt: INSTALLED_AT }
|
||||
]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf-elsewhere', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'else', 'codex'), installDir: codexElse, installedAt: INSTALLED_AT }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'codex', '--dir', join(home, 'here'), '--json'],
|
||||
{ HOME: home, USERPROFILE: home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.slug).toBe('pdf')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: SKILLHUB_REGISTRY env scopes list to the env-specified registry
|
||||
// (registry priority --registry > env > config > default also applies to
|
||||
// list, not just to network-touching commands).
|
||||
// -------------------------------------------------------------------------
|
||||
test('SKILLHUB_REGISTRY env scopes list to that registry, hiding the other', async () => {
|
||||
const { home } = await createTempHome()
|
||||
const dirA = join(home, 'a', 'codex', 'one')
|
||||
const dirB = join(home, 'b', 'codex', 'two')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'one', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: dirA, installedAt: INSTALLED_AT }]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'two', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: dirB, installedAt: INSTALLED_AT }]
|
||||
}
|
||||
])
|
||||
|
||||
// No --registry flag — scope comes from SKILLHUB_REGISTRY env.
|
||||
const result = await runCli(
|
||||
['list', '--json'],
|
||||
{ HOME: home, USERPROFILE: home, SKILLHUB_REGISTRY: FAKE_REGISTRY_B }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.slug).toBe('two')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
110
cli/test/integration/multi-registry.test.ts
Normal file
110
cli/test/integration/multi-registry.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Multi-registry credential isolation.
|
||||
*
|
||||
* credentials.json keys tokens by registry URL. Operations on one registry
|
||||
* must not leak into another. These tests cover:
|
||||
* - Logging into A then B preserves both tokens.
|
||||
* - Logging out of A leaves B's token intact.
|
||||
* - whoami after logout reflects per-registry session state.
|
||||
* - Re-login to A overwrites only A's slot.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let regA: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let regB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
regA?.stop(); regA = undefined
|
||||
regB?.stop(); regB = undefined
|
||||
})
|
||||
|
||||
async function readCreds(home: string): Promise<{ tokens: Record<string, string> }> {
|
||||
return JSON.parse(await readFile(join(home, '.skillhub', 'credentials.json'), 'utf-8'))
|
||||
}
|
||||
|
||||
describe('multi-registry credential isolation', () => {
|
||||
test('login to A then B leaves both tokens in credentials.json', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', regA.url, '--token', 'sk_a'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
await runCli(
|
||||
['login', '--registry', regB.url, '--token', 'sk_b'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
|
||||
test('logout from A removes A token while B token survives', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBeUndefined()
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
|
||||
test('whoami after logout-A: A reports not-logged-in, B still authenticates', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a-user', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b-user', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const whoamiA = await runCli(
|
||||
['whoami', '--registry', regA.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(whoamiA.exitCode).toBe(2) // EXIT.auth
|
||||
expect(whoamiA.stderr.toLowerCase()).toContain('not logged in')
|
||||
|
||||
const whoamiB = await runCli(
|
||||
['whoami', '--registry', regB.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(whoamiB.exitCode).toBe(0)
|
||||
expect(whoamiB.stdout).toContain('b-user')
|
||||
})
|
||||
|
||||
test('re-login to A overwrites only A entry; B token unchanged', async () => {
|
||||
const env = await createTempHome()
|
||||
// Don't pin a token on either registry so any value passes whoami; we
|
||||
// only care about credentials.json bookkeeping here.
|
||||
regA = await startFakeRegistry({ user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_old'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
{
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a_old')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
}
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_new'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a_new')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
})
|
||||
|
|
@ -237,3 +237,295 @@ describe('publish command — P1', () => {
|
|||
expect(result.stderr).toContain('registry')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — content shape: directory layout and edge files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
|
||||
describe('publish command — content shape', () => {
|
||||
/**
|
||||
* Spin up a publish endpoint that captures the raw zip body and lets us
|
||||
* inspect entries server-side. Returns the captured bytes alongside a
|
||||
* stop() handle so tests can assert what the CLI actually packaged.
|
||||
*/
|
||||
async function startCapturingPublishServer() {
|
||||
let capturedBytes: Uint8Array | null = null
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
const form = await req.formData()
|
||||
const file = form.get('file')
|
||||
if (file instanceof File) {
|
||||
capturedBytes = new Uint8Array(await file.arrayBuffer())
|
||||
}
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { namespace: 'global', slug: 'captured', version: '1.0.0', visibility: 'PUBLIC' }
|
||||
})
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
return {
|
||||
url: `http://localhost:${server.port}`,
|
||||
stop: () => server.stop(),
|
||||
getCaptured: () => capturedBytes
|
||||
}
|
||||
}
|
||||
|
||||
test('publishing a directory with subdirs packages every file at its relative path', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = await startCapturingPublishServer()
|
||||
try {
|
||||
await login(env, server.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-nested-'))
|
||||
await writeFile(join(dir, 'SKILL.md'), '# nested')
|
||||
await mkdir(join(dir, 'references'), { recursive: true })
|
||||
await writeFile(join(dir, 'references', 'a.md'), 'aa')
|
||||
await mkdir(join(dir, 'scripts'), { recursive: true })
|
||||
await writeFile(join(dir, 'scripts', 'run.sh'), '#!/bin/sh\necho ok\n')
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', server.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const captured = server.getCaptured()
|
||||
expect(captured).not.toBeNull()
|
||||
const rawEntries = unzipSync(captured!)
|
||||
// Normalize all entry keys to use forward slashes for cross-platform compatibility
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
|
||||
)
|
||||
// Filter out directory marker entries (zip records empty entries for
|
||||
// dirs with a trailing slash); we only care about file entries.
|
||||
const files = Object.keys(entries).filter(k => !k.endsWith('/')).sort()
|
||||
expect(files).toEqual([
|
||||
'SKILL.md',
|
||||
'references/a.md',
|
||||
'scripts/run.sh'
|
||||
])
|
||||
expect(strFromU8(entries['SKILL.md']!)).toBe('# nested')
|
||||
expect(strFromU8(entries['references/a.md']!)).toBe('aa')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing a directory with hidden dotfiles packages them as-is', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = await startCapturingPublishServer()
|
||||
try {
|
||||
await login(env, server.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-hidden-'))
|
||||
await writeFile(join(dir, 'SKILL.md'), '# h')
|
||||
await writeFile(join(dir, '.DS_Store'), 'macos junk')
|
||||
await writeFile(join(dir, '.editorconfig'), 'root = true\n')
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', server.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const captured = server.getCaptured()
|
||||
expect(captured).not.toBeNull()
|
||||
const rawEntries = unzipSync(captured!)
|
||||
// Normalize all entry keys to use forward slashes for cross-platform compatibility
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
|
||||
)
|
||||
// Pin current behavior so future filtering changes are intentional.
|
||||
expect(Object.keys(entries).sort()).toEqual(['.DS_Store', '.editorconfig', 'SKILL.md'])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing an empty directory still issues a request and reports the server outcome', async () => {
|
||||
const env = await createTempHome()
|
||||
// Fake registry accepts publish unconditionally; CLI is not authoritative
|
||||
// on SKILL.md presence (server is). We assert only that the CLI does not
|
||||
// crash client-side and exits with whatever the server returned.
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-empty-'))
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', registry.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
// Today's contract: empty dir → empty zip uploaded → server returns 200.
|
||||
// If the server adds client-side or server-side validation later this
|
||||
// assertion will need to flip; that's intentional and traceable.
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test('server 422 with a JSON validation body surfaces a non-zero exit and stderr', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json(
|
||||
{ code: 422, message: 'validation.token.name.size', errors: ['name exceeds 64 chars'] },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// The server's HTTP status should propagate visibly so a CI log
|
||||
// shows what happened.
|
||||
expect(result.stderr).toMatch(/422|registry|validation/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// 502/503 are special-cased to EXIT.network because they indicate
|
||||
// infrastructure-level unavailability (gateway/proxy failure).
|
||||
test('server 503 Service Unavailable maps to EXIT.network with status in stderr', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 503, message: 'service unavailable' }, { status: 503 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(3) // EXIT.network
|
||||
expect(result.stderr).toMatch(/503|registry/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('server 401 mid-session (token revoked) maps to EXIT.auth', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
// Whoami succeeds (login step). Publish then returns 401 as if the
|
||||
// server revoked the token between the login + publish calls.
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(2) // EXIT.auth
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publish response missing required fields is handled without crash', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
// 200 OK but body shape doesn't match the expected schema.
|
||||
return Response.json({ code: 0, data: { unexpected: true } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url, '--json'], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
// Either parses with placeholder values or fails — the contract we
|
||||
// want is "no crash". Pin: exit 0 means current behavior accepts
|
||||
// partial responses; flip if/when stricter validation lands.
|
||||
expect([0, 1, 2, 3]).toContain(result.exitCode)
|
||||
// Either way, output is bounded — no stack trace dump.
|
||||
expect((result.stdout + result.stderr).length).toBeLessThan(2000)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('server 413 Payload Too Large maps to a network-class non-zero exit', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 413, message: 'payload too large' }, { status: 413 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/413|registry/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
177
cli/test/integration/publish-dry-run.test.ts
Normal file
177
cli/test/integration/publish-dry-run.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop()
|
||||
registry = undefined
|
||||
})
|
||||
|
||||
async function login(env: { home: string }, registryUrl: string) {
|
||||
const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`login failed: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeTempDir(...files: Array<[string, string]>) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-dryrun-'))
|
||||
for (const [name, content] of files) {
|
||||
await writeFile(join(dir, name), content)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('publish --dry-run', () => {
|
||||
test('calls validate endpoint and reports success', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Validation passed')
|
||||
expect(registry.received.validate).not.toBeNull()
|
||||
expect(registry.received.validate!.namespace).toBe('global')
|
||||
expect(registry.received.publish).toBeNull()
|
||||
})
|
||||
|
||||
test('--dry-run with --json returns structured response on warnings (valid=false)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
dryRunResponse: {
|
||||
valid: false,
|
||||
errors: [],
|
||||
warnings: ['Disallowed file extension: data.bin'],
|
||||
resolvedSlug: 'my-skill',
|
||||
resolvedVersion: '2.0.0'
|
||||
}
|
||||
})
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(6)
|
||||
const json = JSON.parse(result.stdout)
|
||||
expect(json.valid).toBe(false)
|
||||
expect(json.resolvedSlug).toBe('my-skill')
|
||||
expect(json.resolvedVersion).toBe('2.0.0')
|
||||
expect(json.warnings).toContain('Disallowed file extension: data.bin')
|
||||
})
|
||||
|
||||
test('--dry-run reports validation errors', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
dryRunResponse: {
|
||||
valid: false,
|
||||
errors: ['Missing required file: SKILL.md at root'],
|
||||
warnings: [],
|
||||
resolvedSlug: null,
|
||||
resolvedVersion: null
|
||||
}
|
||||
})
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['README.md', '# No SKILL.md here'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(6)
|
||||
expect(result.stdout).toContain('Validation failed')
|
||||
expect(result.stdout).toContain('Missing required file: SKILL.md at root')
|
||||
})
|
||||
|
||||
test('--dry-run does not actually publish', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.publish).toBeNull()
|
||||
})
|
||||
|
||||
test('--dry-run respects --namespace', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.validate!.namespace).toBe('myteam')
|
||||
})
|
||||
|
||||
test('--dry-run forwards --visibility to server', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--visibility', 'private', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.validate!.visibility).toBe('PRIVATE')
|
||||
})
|
||||
|
||||
test('--dry-run requires authentication', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
|
||||
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('authentication')
|
||||
})
|
||||
|
||||
test('--dry-run reports scope error on 403', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
|
||||
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('scope')
|
||||
expect(result.stderr).toContain('Request ID: req-test-forbidden')
|
||||
})
|
||||
})
|
||||
|
|
@ -320,4 +320,111 @@ describe('remove command — local remove (P1)', () => {
|
|||
const agents = parsed.removed.map((r: { agent: string }) => r.agent).sort()
|
||||
expect(agents).toEqual(['claude-code', 'cursor'])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --remote --hard against a slug that doesn't exist on the server
|
||||
// -------------------------------------------------------------------------
|
||||
test('--remote --hard for a nonexistent slug surfaces server 404 as non-zero exit', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' }
|
||||
// No skills configured → DELETE returns 404.
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'remove', 'never-published',
|
||||
'--remote', '--hard',
|
||||
'--namespace', 'global',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/404|not found|registry returned 4/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --agent on a multi-target inventory leaves OTHER agents' targets
|
||||
// intact in the inventory file (not just in the JSON envelope).
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent removes one target while leaving others in inventory.json', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
|
||||
const rootDir = `${env.home}/agents`
|
||||
const codexDir = `${rootDir}/codex/skills/keep-others`
|
||||
const claudeDir = `${rootDir}/claude-code/skills/keep-others`
|
||||
await createInstallDir(codexDir)
|
||||
await createInstallDir(claudeDir)
|
||||
|
||||
await seedInventory(env.home, [
|
||||
{
|
||||
registry: registry.url,
|
||||
namespace: 'global',
|
||||
slug: 'keep-others',
|
||||
version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: codexDir, installedAt: '2026-04-20T00:00:00Z' },
|
||||
{ agent: 'claude-code', rootDir: `${rootDir}/claude-code`, installDir: claudeDir, installedAt: '2026-04-20T00:00:00Z' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['remove', 'keep-others', '--agent', 'codex', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
|
||||
items: Array<{ slug: string; targets: Array<{ agent: string }> }>
|
||||
}
|
||||
const survived = inv.items.find(i => i.slug === 'keep-others')
|
||||
expect(survived).toBeDefined()
|
||||
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 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)
|
||||
|
||||
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' }]
|
||||
}
|
||||
])
|
||||
|
||||
// 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],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
|
||||
items: Array<{ slug: string }>
|
||||
}
|
||||
const slugs = inv.items.map(i => i.slug).sort()
|
||||
expect(slugs).toEqual(['dup-slug-B'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
|
|
@ -103,4 +206,235 @@ describe('search command', () => {
|
|||
expect(result.exitCode).toBe(3) // EXIT.network
|
||||
expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: query containing non-ASCII characters must be URL-encoded in the
|
||||
// outgoing request. We capture the raw URL via a custom Bun.serve and
|
||||
// assert the q parameter is the percent-encoded UTF-8 form of "中文测试".
|
||||
// -------------------------------------------------------------------------
|
||||
test('non-ASCII query is URL-encoded as UTF-8 percent escapes', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', '中文测试', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
// UTF-8 of 中文测试 = E4 B8 AD E6 96 87 E6 B5 8B E8 AF 95
|
||||
expect(capturedUrl).toContain('q=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: queries containing special characters (script tags, ampersands,
|
||||
// equals signs) are percent-encoded so they don't break the query string.
|
||||
// -------------------------------------------------------------------------
|
||||
test('special-character query is encoded so the URL stays parseable', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', '<script>&q=evil', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
// Re-parse the captured URL and read q via URLSearchParams to confirm
|
||||
// the original payload survives a round-trip without splitting.
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe('<script>&q=evil')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: --limit 0 still forwards limit=0 to the registry. The CLI does not
|
||||
// validate boundary values; the server contract decides how to respond.
|
||||
// We assert the CLI forwards faithfully and exits cleanly when the server
|
||||
// returns an empty list.
|
||||
// -------------------------------------------------------------------------
|
||||
test('--limit 0 forwards limit=0 and renders no skills', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 0 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', 'pdf', '--limit', '0', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('limit=0')
|
||||
expect(result.stdout).toBe('No skills found.')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: 5xx server response. Per commit a14d89d8 (refactor: unify
|
||||
// download/handleJsonResponse error mapping) non-2xx responses surface
|
||||
// as EXIT.generic (1), distinct from EXIT.network (3) which is reserved
|
||||
// for "couldn't even reach the registry". stderr still carries the HTTP
|
||||
// status so the user can debug.
|
||||
// -------------------------------------------------------------------------
|
||||
test('5xx server error returns EXIT.generic with status in stderr', async () => {
|
||||
registry = await startFakeRegistry({ failures: { search: 'server_error' } })
|
||||
|
||||
const result = await runCli(['search', 'pdf', '--registry', registry.url])
|
||||
|
||||
expect(result.exitCode).toBe(1) // EXIT.generic
|
||||
expect(result.stderr).toMatch(/registry returned 500/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: extra --limit values, including high integers and negative inputs.
|
||||
// CLI does not validate; server contract decides. We only assert the
|
||||
// outgoing URL faithfully reflects the user's input.
|
||||
// -------------------------------------------------------------------------
|
||||
test('--limit 100 forwards limit=100 in the search URL', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 100 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const result = await runCli(['search', 'pdf', '--limit', '100', '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('limit=100')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('query with + and = characters round-trips faithfully through URL encoding', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const tricky = 'a+b=c&d e'
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('1KB long query is forwarded without truncation', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const longQuery = 'q'.repeat(1024)
|
||||
const result = await runCli(['search', longQuery, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(longQuery)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('literal % in query is encoded so it survives a round-trip without being mistaken for an escape', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
// A literal '%' must be escaped as %25 so the server doesn't read it
|
||||
// as the start of an existing escape sequence.
|
||||
const tricky = '50% off'
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('%25')
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('query with multiple shell metacharacters survives both shell quoting and URL encoding', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const tricky = "$VAR `cmd` 'quote' \"dq\""
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
162
cli/test/integration/version-upgrade-flow.test.ts
Normal file
162
cli/test/integration/version-upgrade-flow.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* CLI skill version-upgrade flow.
|
||||
*
|
||||
* These tests cover scenarios that span multiple `install` invocations
|
||||
* against a registry whose state changes between runs — i.e. the user-facing
|
||||
* "upgrade an installed skill" workflow. They complement the per-command
|
||||
* tests in install-command.test.ts which use a single static registry.
|
||||
*
|
||||
* Coverage focus (per test-case-design-skill methodology):
|
||||
* - VU1 (state-transition): full v1 → v2 upgrade lifecycle. Asserts that
|
||||
* metadata.json, inventory.json AND the on-disk bundle all reflect v2
|
||||
* after `install --force`, with no orphaned v1 entry left behind.
|
||||
* - VU2 (equivalence-class on `--version`): pinning to a specific version
|
||||
* forwards `?version=` to /resolve so the registry can serve the
|
||||
* intended bundle.
|
||||
*/
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
|
||||
function makeSkillZipWithBody(body: string): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8(body) })
|
||||
}
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop()
|
||||
registry = undefined
|
||||
})
|
||||
|
||||
describe('version upgrade flow', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// VU1 — full upgrade lifecycle:
|
||||
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
|
||||
// 2. Stop registry, start a new one serving pdf-parser@2.0.0
|
||||
// 3. Install --force using the new registry URL
|
||||
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
|
||||
// -------------------------------------------------------------------------
|
||||
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
|
||||
const env = await createTempHome()
|
||||
|
||||
// --- Stage 1: install v1 ----------------------------------------------
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
|
||||
}]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-upgrade')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
|
||||
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
|
||||
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
|
||||
|
||||
{
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.version).toBe('1.0.0')
|
||||
const body = await readFile(skillFile, 'utf-8')
|
||||
expect(body).toContain('Version one body.')
|
||||
}
|
||||
|
||||
// --- Stage 2: swap registry to v2 -------------------------------------
|
||||
registry.stop()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '2.0.0',
|
||||
zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
|
||||
}]
|
||||
})
|
||||
|
||||
// Re-login against the new registry (URL changed, so credentials are
|
||||
// keyed differently).
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
// --- Stage 3: assert end state ----------------------------------------
|
||||
const finalMeta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(finalMeta.version).toBe('2.0.0')
|
||||
expect(finalMeta.registry).toBe(registry.url)
|
||||
|
||||
const finalBody = await readFile(skillFile, 'utf-8')
|
||||
expect(finalBody).toContain('Version two body.')
|
||||
expect(finalBody).not.toContain('Version one body.')
|
||||
|
||||
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as {
|
||||
items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }>
|
||||
}
|
||||
const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser')
|
||||
expect(matching).toHaveLength(1) // no duplicate v1 entry
|
||||
expect(matching[0]?.version).toBe('2.0.0')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// VU2 — version pinning:
|
||||
// `install --version=X` must forward ?version=X to /resolve so the
|
||||
// registry can serve the requested bundle. The fake registry captures
|
||||
// the resolve query for assertion.
|
||||
// -------------------------------------------------------------------------
|
||||
test('VU2 --version pins resolve to the requested version string', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.5.0',
|
||||
zipBytes: makeSkillZipWithBody('# pinned')
|
||||
}]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-pin')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--version', '1.5.0',
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.namespace).toBe('global')
|
||||
expect(registry.received.resolve?.slug).toBe('pdf-parser')
|
||||
expect(registry.received.resolve?.version).toBe('1.5.0')
|
||||
|
||||
const meta = JSON.parse(await readFile(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'), 'utf-8'))
|
||||
expect(meta.version).toBe('1.5.0')
|
||||
})
|
||||
})
|
||||
|
|
@ -98,4 +98,54 @@ describe('whoami command', () => {
|
|||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr.toLowerCase()).toContain('authentication failed')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Stored-token revocation: token persists in credentials.json but
|
||||
// server now rejects it. whoami must surface the auth failure on first
|
||||
// call, not silently use a stale principal.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('stored token that the server now rejects surfaces EXIT.auth on whoami', async () => {
|
||||
// Configure a registry that requires sk_new but seed sk_old in credentials
|
||||
// — simulates a token that was valid at login time but has since been
|
||||
// revoked or rotated server-side.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_new',
|
||||
user: { handle: 'should-not-see', displayName: 'X' }
|
||||
})
|
||||
const { home } = await createTempHome()
|
||||
const env = { HOME: home, USERPROFILE: home }
|
||||
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'credentials.json'),
|
||||
JSON.stringify({ tokens: { [registry.url]: 'sk_old_revoked' } })
|
||||
)
|
||||
|
||||
const result = await runCli(['whoami', '--registry', registry.url], env)
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Token priority --token > SKILLHUB_TOKEN > stored, end-to-end via
|
||||
// whoami. Cross-checks the auth-resolution suite by verifying the wired
|
||||
// contract on this specific command.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('--token wins over SKILLHUB_TOKEN env on whoami', async () => {
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_winner',
|
||||
user: { handle: 'winner', displayName: 'W' }
|
||||
})
|
||||
const { home } = await createTempHome()
|
||||
const env = { HOME: home, USERPROFILE: home, SKILLHUB_TOKEN: 'sk_loser_env' }
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--token', 'sk_winner', '--registry', registry.url],
|
||||
env
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('winner')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
76
cli/test/unit/agents/resolver-interactive.test.ts
Normal file
76
cli/test/unit/agents/resolver-interactive.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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: selectPromptTargets(options) }
|
||||
}
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
selectPromptTargets = defaultSelectedTargets
|
||||
})
|
||||
|
||||
const { resolveInstallTargets } = await import('../../../src/agents/resolver')
|
||||
|
||||
describe('resolveInstallTargets interactive prompt', () => {
|
||||
test('uses the highlighted target when Enter submits an empty multiselect', async () => {
|
||||
const detected: AgentCandidate[] = [
|
||||
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
|
||||
{ agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' }
|
||||
]
|
||||
const highlighted = detected[1]!
|
||||
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
agents: [],
|
||||
json: false,
|
||||
interactive: true,
|
||||
detected
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -44,6 +48,18 @@ describe('resolveInstallTargets', () => {
|
|||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('explicit agent without scope labels root by userRoots membership when cwd === home', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/home/u',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
|
||||
})
|
||||
|
||||
test('deduplicates repeated explicit agents by target root', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
|
|
@ -89,4 +105,151 @@ describe('resolveInstallTargets', () => {
|
|||
interactive: false
|
||||
})).rejects.toThrow('unknown agent: unknown-agent')
|
||||
})
|
||||
|
||||
test('rejects dir and scope together', async () => {
|
||||
await expect(resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
dir: '/tmp/skills',
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})).rejects.toThrow('--dir cannot be used with --scope')
|
||||
})
|
||||
|
||||
test('scope=project + agent codex returns project root with project scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'project',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('scope=user + agent codex returns user root with user scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('scope=user + cwd === home + agent codex still labels candidate as user', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/home/u',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
})
|
||||
|
||||
test('scope=user clean env falls back to user agents skills', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/nonexistent-home-' + Math.random().toString(36).slice(2),
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{
|
||||
agent: 'generic',
|
||||
rootDir: targets[0]!.rootDir,
|
||||
scope: 'user',
|
||||
source: 'fallback'
|
||||
}])
|
||||
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
|
||||
expect(targets[0]!.rootDir.startsWith('/nonexistent-home-')).toBe(true)
|
||||
})
|
||||
|
||||
test('scope=project clean env falls back to cwd agents skills', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/nonexistent-repo-' + Math.random().toString(36).slice(2),
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'project',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]!.scope).toBe('project')
|
||||
expect(targets[0]!.source).toBe('fallback')
|
||||
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
|
||||
})
|
||||
|
||||
test('scope filters detected candidates and falls back when filtered empty', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false,
|
||||
detected: [
|
||||
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }
|
||||
]
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]!.source).toBe('fallback')
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
})
|
||||
|
||||
test('scope filters detected candidates keeps matching scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false,
|
||||
detected: [
|
||||
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
|
||||
{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' }
|
||||
]
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,12 +37,21 @@ describe('SkillHubClient', () => {
|
|||
})
|
||||
|
||||
test('download() throws auth error on 403', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch
|
||||
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)
|
||||
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: 'API token is missing required scope: skill:read',
|
||||
exitCode: EXIT.auth,
|
||||
details: {
|
||||
registry: 'http://registry.test',
|
||||
requestId: 'req-download'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('download() throws not-found error on 404', async () => {
|
||||
|
|
@ -159,6 +168,35 @@ describe('SkillHubClient', () => {
|
|||
|
||||
// --- handleJsonResponse() non-2xx classification ---
|
||||
|
||||
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() throws generic error on 500', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
|
|
|||
200
cli/test/unit/commands/install-command.test.ts
Normal file
200
cli/test/unit/commands/install-command.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { CliError } from '../../../src/shared/errors'
|
||||
import {
|
||||
computeStrictIsTTY,
|
||||
installCommand,
|
||||
resolveEffectiveScope,
|
||||
type InstallCommandDeps,
|
||||
type InstallCommandOptions
|
||||
} from '../../../src/commands/install'
|
||||
import type { AgentCandidate } from '../../../src/agents/types'
|
||||
import type { ResolveInstallTargetOptions } from '../../../src/agents/resolver'
|
||||
|
||||
describe('computeStrictIsTTY', () => {
|
||||
test('true when stdin and stdout are TTY and not json', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: false })).toBe(true)
|
||||
})
|
||||
|
||||
test('false when stdin is not TTY', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: false, stdoutIsTTY: true, json: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('false when stdout is not TTY', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: false, json: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('false when json is true', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEffectiveScope', () => {
|
||||
function neverPrompt(): Promise<'user' | 'project'> {
|
||||
throw new Error('promptScope should not be called')
|
||||
}
|
||||
|
||||
test('rejects invalid --scope value', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ scope: 'team' } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--scope must be "user" or "project"')
|
||||
})
|
||||
|
||||
test('rejects --dir with --scope', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ scope: 'user', dir: '/tmp/x' } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--dir cannot be used with --scope')
|
||||
})
|
||||
|
||||
test('rejects --dir with --agent', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ dir: '/tmp/x', agent: ['codex'] } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--dir cannot be used with --agent')
|
||||
})
|
||||
|
||||
test('returns explicit --scope value', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ scope: 'user' } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
})
|
||||
|
||||
test('--agent without --scope returns undefined (regression protection)', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ agent: ['codex'] } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('--dir without --scope returns undefined (regression protection)', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ dir: '/tmp/x' } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('non-interactive bare install returns undefined without calling promptScope', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('interactive bare install calls promptScope and returns user', async () => {
|
||||
let calls = 0
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { calls++; return 'user' }
|
||||
}
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('interactive bare install + promptScope returns project', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: async () => 'project' }
|
||||
)
|
||||
expect(scope).toBe('project')
|
||||
})
|
||||
|
||||
test('interactive bare install + promptScope cancel propagates CliError', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { throw new CliError('installation cancelled', 5) }
|
||||
}
|
||||
)).rejects.toThrow('installation cancelled')
|
||||
})
|
||||
|
||||
test('empty agent array does not skip promptScope', async () => {
|
||||
let calls = 0
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ agent: [] } as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { calls++; return 'user' }
|
||||
}
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCommand dependency injection', () => {
|
||||
function fakeInstallSkill(): NonNullable<InstallCommandDeps['installSkill']> {
|
||||
return async () => ({ installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }] })
|
||||
}
|
||||
|
||||
test('passes prompted scope and strict isTTY into resolveInstallTargets', async () => {
|
||||
const calls: { promptScope: number; resolverCalls: ResolveInstallTargetOptions[] } = {
|
||||
promptScope: 0,
|
||||
resolverCalls: []
|
||||
}
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => true,
|
||||
promptScope: async () => { calls.promptScope++; return 'user' },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
calls.resolverCalls.push(opts)
|
||||
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
|
||||
|
||||
expect(calls.promptScope).toBe(1)
|
||||
expect(calls.resolverCalls).toHaveLength(1)
|
||||
expect(calls.resolverCalls[0]!.scope).toBe('user')
|
||||
expect(calls.resolverCalls[0]!.interactive).toBe(true)
|
||||
})
|
||||
|
||||
test('does not call promptScope when --agent is provided', async () => {
|
||||
let promptCalls = 0
|
||||
let resolverScope: 'user' | 'project' | undefined = 'user'
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => true,
|
||||
promptScope: async () => { promptCalls++; return 'user' },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
resolverScope = opts.scope
|
||||
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', {
|
||||
registry: 'http://localhost',
|
||||
token: 'sk',
|
||||
agent: ['codex']
|
||||
}, deps)
|
||||
|
||||
expect(promptCalls).toBe(0)
|
||||
expect(resolverScope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('passes interactive=false when isTTY returns false', async () => {
|
||||
let interactiveFlag: boolean | undefined
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => false,
|
||||
promptScope: async () => { throw new Error('should not be called') },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
interactiveFlag = opts.interactive
|
||||
return [{ agent: 'generic', rootDir: '/tmp/.agents/skills', scope: 'project', source: 'fallback' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
|
||||
expect(interactiveFlag).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -91,4 +91,173 @@ describe('updateCommand branches', () => {
|
|||
expect(caught).toBeInstanceOf(CliError)
|
||||
expect((caught as CliError).message).toContain('install command failed')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// bun-global success branch — symmetric to npm-global success but routes
|
||||
// through `bun add -g` rather than `npm install -g`. Command-level output
|
||||
// still uses the same "Updated skillhub X -> Y" copy, but the run dep
|
||||
// receives a different argv. We assert both: (a) the command captured the
|
||||
// bun argv on the dep and (b) the output formatting matches.
|
||||
// -------------------------------------------------------------------------
|
||||
test('updated success branch — bun-global with run() success captures bun argv (human + json)', async () => {
|
||||
const calls: string[][] = []
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'bun-global',
|
||||
run: async (cmd) => {
|
||||
calls.push([...cmd])
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain(`Updated skillhub ${CLI_VERSION} -> 99.0.0`)
|
||||
|
||||
const json = await updateCommand({ json: true }, deps)
|
||||
expect(JSON.parse(json)).toEqual({ ok: true, updated: true, from: CLI_VERSION, to: '99.0.0' })
|
||||
|
||||
// Both invocations must have routed through `bun add -g` — never `npm`.
|
||||
expect(calls).toHaveLength(2)
|
||||
for (const cmd of calls) {
|
||||
expect(cmd[0]).toBe('bun')
|
||||
expect(cmd[1]).toBe('add')
|
||||
expect(cmd[2]).toBe('-g')
|
||||
expect(cmd[3]).toMatch(/@latest$/)
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// unknown install mode — falls through to the manual-upgrade hint. The
|
||||
// command must NOT invoke run() (we'd be guessing the package manager),
|
||||
// and the human output must spell out both npm and bun fallbacks so the
|
||||
// user can pick whichever is on their system.
|
||||
// -------------------------------------------------------------------------
|
||||
test('available-not-updated branch — unknown mode emits manual upgrade hint and never calls run()', async () => {
|
||||
let runCalls = 0
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'unknown',
|
||||
run: async () => {
|
||||
runCalls += 1
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain(`Update available: ${CLI_VERSION} -> 99.0.0`)
|
||||
expect(human).toContain('npm install -g')
|
||||
expect(human).toContain('bun add -g')
|
||||
|
||||
const json = await updateCommand({ json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
expect(typeof parsed.next).toBe('string')
|
||||
expect(parsed.next).toMatch(/npm install -g.*bun add -g|bun add -g.*npm install -g/)
|
||||
|
||||
// Neither invocation should have attempted to spawn an installer.
|
||||
expect(runCalls).toBe(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkOnly short-circuit — even in install modes that WOULD execute an
|
||||
// upgrade (npm-global, bun-global), passing { check: true } must short
|
||||
// circuit the service before it reaches `run()`. Output is the bare
|
||||
// "Update available" line with no `next` hint (next is only populated
|
||||
// for npx / unknown branches, not on checkOnly's early return).
|
||||
// -------------------------------------------------------------------------
|
||||
test('checkOnly with npm-global short-circuits and never calls run()', async () => {
|
||||
let runCalls = 0
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'npm-global',
|
||||
run: async () => {
|
||||
runCalls += 1
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({ check: true }, deps)
|
||||
expect(human.trim()).toBe(`Update available: ${CLI_VERSION} -> 99.0.0`)
|
||||
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
// No `next` hint on the checkOnly path.
|
||||
expect(parsed.next).toBeUndefined()
|
||||
|
||||
expect(runCalls).toBe(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Version comparison edge cases. semver.gt drives the available
|
||||
// gate; these tests pin the boundary where "no upgrade" must hold.
|
||||
// -------------------------------------------------------------------------
|
||||
test('latest version equal to current is reported as up-to-date', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: CLI_VERSION,
|
||||
mode: 'npm-global',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain('Already up to date')
|
||||
})
|
||||
|
||||
test('latest version older than current is treated as up-to-date (no downgrade)', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: '0.0.1',
|
||||
mode: 'npm-global',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain('Already up to date')
|
||||
// Make sure we didn't accidentally invoke npm with an older version.
|
||||
const jsonOut = await updateCommand({ json: true }, deps)
|
||||
expect(JSON.parse(jsonOut).updated).toBeUndefined()
|
||||
})
|
||||
|
||||
test('checkOnly + already up-to-date emits a non-available envelope', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: CLI_VERSION,
|
||||
mode: 'unknown',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed).toMatchObject({ ok: true, upToDate: true, version: CLI_VERSION })
|
||||
})
|
||||
|
||||
test('bun-global run() failure surfaces the failure output as a CliError message', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: '99.0.0',
|
||||
mode: 'bun-global',
|
||||
runResult: { success: false, output: 'bun add: permission denied' }
|
||||
})
|
||||
let caught: unknown
|
||||
try {
|
||||
await updateCommand({}, deps)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CliError)
|
||||
expect((caught as CliError).message).toContain('bun add')
|
||||
})
|
||||
|
||||
test('checkOnly with npx emits Update-available envelope WITHOUT a next hint (next is mode-specific)', async () => {
|
||||
// Per service code, npx's `next` is added only when checkOnly is false.
|
||||
// checkOnly returns earlier and never enters the mode switch.
|
||||
const deps = buildDeps({
|
||||
latest: '99.0.0',
|
||||
mode: 'npx',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
expect(parsed.next).toBeUndefined()
|
||||
expect(parsed.from).toBe(CLI_VERSION)
|
||||
expect(parsed.to).toBe('99.0.0')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ describe('archive helpers', () => {
|
|||
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
|
||||
})
|
||||
|
||||
test('rejects unsafe zip before writing earlier safe entries', async () => {
|
||||
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-'))
|
||||
const unsafe = zipSync({
|
||||
'SKILL.md': new TextEncoder().encode('# Partial'),
|
||||
'../escape.txt': new TextEncoder().encode('bad'),
|
||||
})
|
||||
|
||||
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
|
||||
await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('rejects zip entries with absolute paths', async () => {
|
||||
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-'))
|
||||
const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') })
|
||||
|
|
@ -56,4 +67,32 @@ describe('archive helpers', () => {
|
|||
const entries = await readdir(target)
|
||||
expect(entries).toEqual([])
|
||||
})
|
||||
|
||||
test('rejects zip archives with more than 500 entries before extraction', async () => {
|
||||
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-'))
|
||||
const manyEntries = Object.fromEntries(
|
||||
Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)])
|
||||
)
|
||||
const archive = zipSync(manyEntries)
|
||||
|
||||
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit')
|
||||
})
|
||||
|
||||
test('rejects zip entries larger than 10 MiB before extraction', async () => {
|
||||
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-'))
|
||||
const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) })
|
||||
|
||||
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit')
|
||||
})
|
||||
|
||||
test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => {
|
||||
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-'))
|
||||
const oneMiB = new Uint8Array(1024 * 1024)
|
||||
const entries = Object.fromEntries(
|
||||
Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB])
|
||||
)
|
||||
const archive = zipSync(entries)
|
||||
|
||||
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -21,6 +21,13 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
|
|||
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
|
||||
))
|
||||
|
||||
return installFetchWithDownloadResponse(new Response(
|
||||
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
|
||||
{ status: 200 }
|
||||
))
|
||||
}
|
||||
|
||||
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
|
||||
const fakeFetch = async (input: URL | RequestInfo) => {
|
||||
const path = new URL(String(input)).pathname
|
||||
if (path.endsWith('/resolve')) {
|
||||
|
|
@ -37,8 +44,7 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
|
|||
})
|
||||
}
|
||||
if (path.endsWith('/download')) {
|
||||
const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer
|
||||
return new Response(body, { status: 200 })
|
||||
return downloadResponse.clone()
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
|
|
@ -66,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-'))
|
||||
|
|
@ -125,4 +187,60 @@ describe('installSkill', () => {
|
|||
expect(inventory.items[0].targets).toHaveLength(1)
|
||||
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
|
||||
})
|
||||
|
||||
test('force keeps old installation and inventory when replacement extraction fails', async () => {
|
||||
globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 }))
|
||||
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
|
||||
const skillDir = join(rootDir, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
|
||||
const inventoryPath = join(home, '.skillhub', 'inventory.json')
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(inventoryPath, JSON.stringify({
|
||||
items: [{
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
version: '0.1.0',
|
||||
targets: [{
|
||||
agent: 'codex',
|
||||
rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt: '2026-04-20T00:00:00.000Z'
|
||||
}]
|
||||
}]
|
||||
}, null, 2))
|
||||
|
||||
await expect(installSkill({
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: true,
|
||||
home
|
||||
})).rejects.toThrow('invalid zip central directory')
|
||||
|
||||
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
|
||||
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
|
||||
expect(inventory.items).toHaveLength(1)
|
||||
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' })
|
||||
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
|
||||
})
|
||||
|
||||
test('rejects downloads whose content-length exceeds the package limit', async () => {
|
||||
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
|
||||
}))
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
|
||||
|
||||
await expect(installSkill({
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: false
|
||||
})).rejects.toThrow('download exceeds maximum package size')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ 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'))
|
||||
})
|
||||
|
|
|
|||
90
cli/test/unit/shared/skill-name-parser.test.ts
Normal file
90
cli/test/unit/shared/skill-name-parser.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
import { parseSkillName } from '../../../src/shared/skill-name-parser'
|
||||
|
||||
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('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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -58,6 +58,7 @@ services:
|
|||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
|
||||
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
|
||||
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
|
||||
STORAGE_BASE_PATH: /var/lib/skillhub/storage
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-}
|
||||
|
|
@ -72,6 +73,7 @@ services:
|
|||
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
|
||||
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
|
||||
SKILLHUB_SECURITY_SCANNER_MODE: upload
|
||||
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
|
||||
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
|
||||
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
|
||||
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}
|
||||
|
|
@ -98,6 +100,8 @@ services:
|
|||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
skill-scanner:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
|
|
@ -114,6 +118,8 @@ services:
|
|||
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
|
||||
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}
|
||||
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER: ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER:-}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -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 模型名称 | 否 |
|
||||
|
||||
### 存储配置
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ stringData:
|
|||
|
||||
# LLM 配置(可选,用于技能扫描)
|
||||
skill-scanner-llm-api-key: ""
|
||||
skill-scanner-llm-base-url: ""
|
||||
skill-scanner-llm-model: ""
|
||||
|
||||
# S3 存储配置(可选,使用 S3/OSS 时配置)
|
||||
|
|
|
|||
|
|
@ -42,11 +42,17 @@ services:
|
|||
BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local
|
||||
OAUTH2_GITHUB_CLIENT_ID: local-placeholder
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED: "true"
|
||||
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
|
||||
SKILLHUB_SECURITY_SCANNER_MODE: upload
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
skill-scanner:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*
|
|||
|
||||
- `SKILL.md` 格式兼容(frontmatter + markdown body)
|
||||
- 技能包目录结构约定(SKILL.md + references/ + scripts/ + assets/)
|
||||
- 四级目录优先级(`.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills`)
|
||||
- 四级目录优先级(`.agents/skills` → `~/.agents/skills` → `.claude/skills` → `~/.claude/skills`)
|
||||
- 目录名作为 lookup key(安装后目录名 = skill slug)
|
||||
- AGENTS.md `<skill>` 描述块格式兼容
|
||||
- 目标:skillhub CLI 安装的技能可被 OpenSkills/Claude 兼容客户端发现和使用
|
||||
|
|
|
|||
|
|
@ -377,7 +377,9 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台
|
|||
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
|
||||
- 存储:只存 SHA-256 哈希,明文只展示一次
|
||||
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
|
||||
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 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(成员空间)+ PRIVATE(owner/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
|
||||
|
|
@ -654,6 +653,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 解析) |
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 |
|
||||
| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 |
|
||||
| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 |
|
||||
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;review 场景下还能审自己的提交 | 可 | 可 |
|
||||
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 |
|
||||
|
||||
### 对象存储写入策略
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | 解封用户 |
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ skillhub 的目标是客户端可互操作:skillhub CLI 安装的技能可以
|
|||
|
||||
- SKILL.md 格式(frontmatter + markdown body)
|
||||
- 技能包目录结构约定(SKILL.md + references/ + scripts/ + assets/)
|
||||
- 四级目录优先级:skillhub CLI 遵循 `.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills` 的发现顺序,与 OpenSkills/Claude 一致
|
||||
- 四级目录优先级:`.agents/skills` → `~/.agents/skills` → `.claude/skills` → `~/.claude/skills`(与 OpenSkills/Claude 一致)。详见 §8.4。
|
||||
- 目录名作为 lookup key:安装后的目录名等于 `skill.slug`(即 SKILL.md 的 `name` 字段),客户端通过目录名发现技能
|
||||
- AGENTS.md `<skill>` 描述块格式:skillhub CLI 生成的 AGENTS.md 索引区块与 OpenSkills 格式兼容
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ my-skill/
|
|||
```
|
||||
|
||||
校验规则:
|
||||
- 根目录必须包含 `SKILL.md`
|
||||
- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md`
|
||||
- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg`
|
||||
- 单文件大小限制:1MB(可配置)
|
||||
- 总包大小限制:10MB(可配置)
|
||||
|
|
@ -78,8 +78,8 @@ skillhub CLI 遵循以下目录优先级,与 OpenSkills/Claude 保持互操作
|
|||
|
||||
| 优先级 | 路径 | 说明 |
|
||||
|--------|------|------|
|
||||
| 1 | `./.agent/skills/` | 项目级,universal 模式 |
|
||||
| 2 | `~/.agent/skills/` | 全局级,universal 模式 |
|
||||
| 1 | `./.agents/skills/` | 项目级,universal 模式 |
|
||||
| 2 | `~/.agents/skills/` | 全局级,universal 模式 |
|
||||
| 3 | `./.claude/skills/` | 项目级,Claude 默认 |
|
||||
| 4 | `~/.claude/skills/` | 全局级,Claude 默认 |
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@
|
|||
- 数据库列统一为 `TIMESTAMPTZ`
|
||||
- 读写都按 UTC 绝对时间处理
|
||||
|
||||
进度登记:
|
||||
|
||||
- `audit_log.created_at` 已通过 V42 迁移到 `TIMESTAMPTZ`,详见 `docs/16-backend-time-inventory.md` §3.1
|
||||
|
||||
### 3.2 业务输入时间
|
||||
|
||||
适用场景:
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@
|
|||
- `review_task.submitted_at / reviewed_at`
|
||||
- `promotion_request.submitted_at / reviewed_at`
|
||||
- `idempotency_record.created_at / expires_at`
|
||||
- `V42__audit_log_created_at_timestamptz.sql`
|
||||
- `audit_log.created_at`
|
||||
|
||||
### 3.2 当前状态
|
||||
|
||||
|
|
|
|||
269
docs/20-cloud-url-builtin-skills-setup.md
Normal file
269
docs/20-cloud-url-builtin-skills-setup.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# 云存储链接内置 Skills 配置指南
|
||||
|
||||
本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills,以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。
|
||||
- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。
|
||||
- 内置 Skill 包已经上传到官方可控的云存储域名。
|
||||
|
||||
## 1. 方案概览
|
||||
|
||||
内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`。
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见
|
||||
```
|
||||
|
||||
核心文件:
|
||||
|
||||
```text
|
||||
server/skillhub-app/src/main/resources/builtin-skills/manifest.json
|
||||
```
|
||||
|
||||
首版 manifest 只需要维护三个字段:
|
||||
|
||||
- `slug`:Skill 在 `@global` 下的 slug。
|
||||
- `version`:期望同步的 Skill 版本。
|
||||
- `url`:Skill zip 包的云存储 HTTPS 链接。
|
||||
|
||||
## 2. Manifest 配置
|
||||
|
||||
manifest 文件格式如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
可以配置多个 Skills,也可以为同一个 `slug` 配置多个版本:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.1.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-guide",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-guide-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
配置要求:
|
||||
|
||||
- `skills` 必须是数组。
|
||||
- 每一项必须同时填写 `slug`、`version`、`url`。
|
||||
- `slug` 必须符合 SkillHub slug 规则。
|
||||
- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。
|
||||
- manifest 最多处理前 100 条 entries。
|
||||
- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。
|
||||
|
||||
## 3. Skill 包要求
|
||||
|
||||
manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议:
|
||||
|
||||
- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`。
|
||||
- `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。
|
||||
- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。
|
||||
- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。
|
||||
- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
skillhub-hello-1.0.0.zip
|
||||
├── SKILL.md
|
||||
├── README.md
|
||||
└── scripts/
|
||||
└── check.js
|
||||
```
|
||||
|
||||
同样支持标准单目录 Skill 包:
|
||||
|
||||
```text
|
||||
skillhub-hello-1.0.0.zip
|
||||
└── skillhub-hello/
|
||||
├── SKILL.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。
|
||||
|
||||
## 4. URL 安全限制
|
||||
|
||||
内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。
|
||||
|
||||
首版只允许:
|
||||
|
||||
- `https://` 协议。
|
||||
- host 为 `bjcdn.openstorage.cn`。
|
||||
- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`。
|
||||
- 默认 HTTPS 端口,或显式 `:443`。
|
||||
|
||||
以下 URL 会被跳过:
|
||||
|
||||
- `http://...`
|
||||
- 非 `bjcdn.openstorage.cn` 及其子域名。
|
||||
- 带 userinfo 的 URL,例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`。
|
||||
- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`。
|
||||
- `localhost`、IP 地址、IPv6 literal 等 host。
|
||||
- 需要 HTTP redirect 才能拿到文件的链接。
|
||||
|
||||
如果某一项 URL 不符合规则,SkillHub 会记录日志并跳过该项,不会阻塞应用启动。
|
||||
|
||||
## 5. 启动同步流程
|
||||
|
||||
应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。
|
||||
|
||||
详细流程:
|
||||
|
||||
1. 检查 `skillhub.builtin-skills.enabled` 是否开启。
|
||||
2. 读取 `classpath:builtin-skills/manifest.json`。
|
||||
3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。
|
||||
4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。
|
||||
5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。
|
||||
6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。
|
||||
7. 按 manifest 顺序处理每一个 item。
|
||||
8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。
|
||||
9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。
|
||||
10. 解包并校验 Skill 入口 `SKILL.md`。
|
||||
11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。
|
||||
12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。
|
||||
13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。
|
||||
14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。
|
||||
|
||||
同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。
|
||||
|
||||
## 6. 幂等与冲突处理
|
||||
|
||||
内置 Skill 同步支持重复启动和多次部署。
|
||||
|
||||
幂等键:
|
||||
|
||||
```text
|
||||
@global/{slug} + version
|
||||
```
|
||||
|
||||
行为说明:
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill |
|
||||
| `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 |
|
||||
| 同版本已存在且已发布 | 下载前跳过 |
|
||||
| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 |
|
||||
| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning |
|
||||
|
||||
这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill;即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。
|
||||
同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。
|
||||
|
||||
如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。
|
||||
|
||||
## 7. 开关配置
|
||||
|
||||
内置 Skill 同步默认开启。
|
||||
|
||||
Spring 配置项:
|
||||
|
||||
```yaml
|
||||
skillhub:
|
||||
builtin-skills:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
```dotenv
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
|
||||
```
|
||||
|
||||
如需禁用启动同步:
|
||||
|
||||
```dotenv
|
||||
SKILLHUB_BUILTIN_SKILLS_ENABLED=false
|
||||
```
|
||||
|
||||
禁用后,应用 ready 后不会读取 manifest,也不会下载或发布任何内置 Skill。
|
||||
|
||||
## 8. 维护流程
|
||||
|
||||
新增一个内置 Skill 的推荐步骤:
|
||||
|
||||
1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。
|
||||
2. 检查 `SKILL.md` 中的 `name` 和 `version`。
|
||||
3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。
|
||||
4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。
|
||||
5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。
|
||||
6. 确保 manifest 中的 `version` 等于 `SKILL.md version`。
|
||||
7. 本地或测试环境启动 SkillHub,查看后端日志确认同步结果。
|
||||
8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。
|
||||
|
||||
更新一个已有内置 Skill 的推荐步骤:
|
||||
|
||||
1. 不要覆盖已经发布过的旧版本 zip 内容。
|
||||
2. 在 `SKILL.md` 中提升 `version`。
|
||||
3. 重新打包并上传新的 zip 文件。
|
||||
4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。
|
||||
5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。
|
||||
|
||||
不推荐:
|
||||
|
||||
- 修改旧版本 zip 内容但保持同一个 `version`。
|
||||
- 把 URL 指向会发生内容变化的临时对象。
|
||||
- 使用需要登录、签名跳转或重定向的下载链接。
|
||||
|
||||
## 9. 日志与排查
|
||||
|
||||
启动时可以通过后端日志观察同步结果。
|
||||
|
||||
常见日志含义:
|
||||
|
||||
| 日志含义 | 处理建议 |
|
||||
|---|---|
|
||||
| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath |
|
||||
| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 |
|
||||
| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 |
|
||||
| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 |
|
||||
| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 |
|
||||
| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 |
|
||||
| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 |
|
||||
| manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 |
|
||||
| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 |
|
||||
| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 |
|
||||
|
||||
如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。
|
||||
|
||||
## 10. 验收检查
|
||||
|
||||
配置或新增内置 Skill 后,建议至少完成以下检查:
|
||||
|
||||
- manifest JSON 格式合法。
|
||||
- 每个 item 都包含 `slug`、`version`、`url`。
|
||||
- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。
|
||||
- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。
|
||||
- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。
|
||||
- `SKILL.md version` 与 manifest `version` 一致。
|
||||
- 启动日志没有该 item 的 warning 或 error。
|
||||
- Web UI 中可以看到 `@global/{slug}`。
|
||||
- Skill 可被匿名或登录用户按公开 Skill 规则发现。
|
||||
278
docs/hermes-integration-en.md
Normal file
278
docs/hermes-integration-en.md
Normal 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
278
docs/hermes-integration.md
Normal 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 skill:Hermes 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 System(v0.18.2)](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ export default defineConfig({
|
|||
ignoreDeadLinks: [/^http:\/\/localhost/],
|
||||
|
||||
head: [],
|
||||
vite: {
|
||||
build: {
|
||||
target: 'es2020',
|
||||
},
|
||||
},
|
||||
|
||||
// Define root locale for redirect
|
||||
locales: {
|
||||
|
|
@ -124,4 +129,4 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -127,6 +129,10 @@ Output format: `namespace/slug version summary`
|
|||
# Install to auto-detected Agent directory
|
||||
skillhub install pdf-parser
|
||||
|
||||
# Choose install scope explicitly
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# Specify namespace (default: global)
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
|
|
@ -150,18 +156,21 @@ skillhub install pdf-parser --force
|
|||
|
||||
The CLI determines the installation location using the following logic:
|
||||
|
||||
1. If `--dir` is specified: Install to that directory, agent marked as `custom`
|
||||
2. If `--agent` is specified: Install to the corresponding Agent's skills directory
|
||||
3. If neither is specified: Auto-scan current directory to detect existing Agent config directories
|
||||
- 1 Agent detected → Install directly
|
||||
- Multiple Agents detected → Interactive selection (TTY mode) or error (non-interactive mode)
|
||||
- No Agent detected → Fallback to `<cwd>/.agents/skills/`
|
||||
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. 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:
|
||||
- **Interactive mode** (stdin and stdout are both TTY, no `--json`): Prompt for `user` or `project` scope first, then continue per the `--scope` rule above.
|
||||
- **Non-interactive mode**: Auto-scan current directory to detect existing Agent config directories. 1 Agent detected → install directly; multiple → error; none detected → fallback to `<cwd>/.agents/skills/`.
|
||||
|
||||
> `--dir` and `--agent` cannot be used together.
|
||||
> `--dir` cannot be combined with `--scope` or `--agent`.
|
||||
|
||||
### Install Paths
|
||||
|
||||
Each Agent has both project-level and user-level skills directories:
|
||||
Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used.
|
||||
|
||||
| Agent | Project-level Path | User-level Path |
|
||||
|-------|-------------------|-----------------|
|
||||
|
|
@ -169,9 +178,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
|
||||
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
|
||||
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
|
||||
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
|
||||
|
|
@ -179,8 +188,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `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.
|
||||
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
|
||||
|
||||
|
|
@ -466,10 +476,11 @@ skillhub install <slug> [options]
|
|||
```
|
||||
|
||||
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`)
|
||||
- `--version <v>` — Version (default: latest)
|
||||
- `--agent <profile>` — Agent profile (repeatable)
|
||||
- `--dir <path>` — Custom installation directory
|
||||
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
|
||||
- `--force` — Overwrite existing installation
|
||||
- `--registry <url>` — Registry URL
|
||||
- `--token <token>` — API token
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/secret,CLI 无法确认跳过;
|
||||
- 文件类型 / 大小 / 路径不合规。
|
||||
|
||||
可用以下命令查看服务端日志定位:
|
||||
|
||||
```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: 通过 OAuth(GitHub / 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -127,6 +129,10 @@ skillhub search pdf --json
|
|||
# 安装到自动探测的 Agent 目录
|
||||
skillhub install pdf-parser
|
||||
|
||||
# 显式指定安装范围
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# 指定 namespace(默认 global)
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
|
|
@ -150,18 +156,21 @@ skillhub install pdf-parser --force
|
|||
|
||||
CLI 按以下逻辑确定安装位置:
|
||||
|
||||
1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`
|
||||
2. 指定 `--agent`:安装到对应 Agent 的 skills 目录
|
||||
3. 未指定:自动扫描当前目录,探测已存在的 Agent 配置目录
|
||||
- 探测到 1 个 Agent → 直接安装
|
||||
- 探测到多个 Agent → 交互式选择(TTY 模式)或报错(非交互模式)
|
||||
- 未探测到 → 回退到 `<cwd>/.agents/skills/`
|
||||
1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`。`--dir` 与 `--scope`、`--agent` 互斥。
|
||||
2. 指定 `--scope user|project`:探测限定在该 scope 内。
|
||||
- 同时指定 `--agent <profile>`:直接安装到该 profile 对应 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. 三者均未指定:
|
||||
- **交互模式**(stdin 和 stdout 都是 TTY 且未传 `--json`):先交互式询问 user 还是 project scope,再按 `--scope` 规则继续。
|
||||
- **非交互模式**:自动扫描当前目录探测已存在的 Agent 配置目录。1 个 → 直接安装;多个 → 报错;未探测到 → 回退到 `<cwd>/.agents/skills/`。
|
||||
|
||||
> `--dir` 和 `--agent` 不能同时使用。
|
||||
> `--dir` 不能与 `--scope` 或 `--agent` 同时使用。
|
||||
|
||||
### 安装路径
|
||||
|
||||
每个 Agent 有项目级和用户级两个 skills 目录:
|
||||
每个 Agent 有项目级和用户级两个 skills 目录。`--scope user|project` 决定使用哪一个。
|
||||
|
||||
| Agent | 项目级路径 | 用户级路径 |
|
||||
|-------|-----------|-----------|
|
||||
|
|
@ -169,9 +178,9 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
|
||||
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
|
||||
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
|
||||
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
|
||||
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
|
||||
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
|
||||
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
|
||||
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
|
||||
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
|
||||
|
|
@ -179,8 +188,9 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
|
||||
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
|
||||
|
||||
对于不在列表中的 Agent,使用 `--dir` 指定安装路径。
|
||||
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。
|
||||
|
||||
### 安装后的文件结构
|
||||
|
||||
|
|
@ -466,10 +476,11 @@ skillhub install <slug> [options]
|
|||
```
|
||||
|
||||
选项:
|
||||
- `--scope <user|project>` — 安装范围(不传时:TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑)
|
||||
- `--namespace <slug>` — namespace(默认 `global`)
|
||||
- `--version <v>` — 版本(默认最新版本)
|
||||
- `--agent <profile>` — Agent 配置(可重复)
|
||||
- `--dir <path>` — 自定义安装目录
|
||||
- `--dir <path>` — 自定义安装目录(与 `--scope`、`--agent` 互斥)
|
||||
- `--force` — 覆盖已存在的安装
|
||||
- `--registry <url>` — Registry URL
|
||||
- `--token <token>` — API token
|
||||
|
|
|
|||
|
|
@ -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. 选择部署方式
|
||||
|
||||
|
|
|
|||
|
|
@ -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 模型名称 | - |
|
||||
|
||||
### 部署说明
|
||||
|
||||
|
|
|
|||
1172
docs/skillhub/package-lock.json
generated
1172
docs/skillhub/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,5 +9,10 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^6.4.3",
|
||||
"postcss": "^8.5.10",
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制(RBAC)系统。
|
|||
|
||||
| 角色 | 代码 | 实际能力 |
|
||||
|------|------|----------|
|
||||
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
|
||||
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion;普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
|
||||
| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 |
|
||||
| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 |
|
||||
| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 |
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles:
|
|||
|
||||
| Role | Code | Effective behavior |
|
||||
|------|------|--------------------|
|
||||
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. |
|
||||
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. |
|
||||
| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. |
|
||||
| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. |
|
||||
| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. |
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
62
scanner/backports/apply_1_0_2_llm_base_url_backport.py
Normal file
62
scanner/backports/apply_1_0_2_llm_base_url_backport.py
Normal 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())
|
||||
|
|
@ -18,7 +18,7 @@ skillhub:
|
|||
security:
|
||||
scanner:
|
||||
# 基础配置
|
||||
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
|
||||
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
|
||||
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
|
||||
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
|
||||
|
||||
|
|
@ -48,20 +48,20 @@ skillhub:
|
|||
#### `enabled`
|
||||
|
||||
- **类型**:Boolean
|
||||
- **默认值**:`false`
|
||||
- **默认值**:`true`
|
||||
- **环境变量**:`SKILLHUB_SECURITY_SCANNER_ENABLED`
|
||||
- **说明**:是否启用安全扫描功能
|
||||
- **影响**:
|
||||
- `true`:技能包发布时会触发安全扫描
|
||||
- `false`:跳过安全扫描,直接进入审核流程
|
||||
- `false`:仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner
|
||||
|
||||
**示例**:
|
||||
|
||||
```yaml
|
||||
# 开发环境:禁用扫描
|
||||
# 仅本地私有技能调试:禁用扫描
|
||||
enabled: false
|
||||
|
||||
# 生产环境:启用扫描
|
||||
# 公共或命名空间可见发布:启用扫描
|
||||
enabled: true
|
||||
```
|
||||
|
||||
|
|
@ -103,8 +103,8 @@ base-url: https://scanner.example.com
|
|||
**示例**:
|
||||
|
||||
```yaml
|
||||
# Docker Compose 环境(共享卷)
|
||||
mode: local
|
||||
# Docker Compose 环境(Scanner 独立容器,无共享发布目录)
|
||||
mode: upload
|
||||
|
||||
# Kubernetes 环境(独立 Pod)
|
||||
mode: upload
|
||||
|
|
@ -298,7 +298,7 @@ services:
|
|||
environment:
|
||||
- SKILLHUB_SECURITY_SCANNER_ENABLED=true
|
||||
- SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000
|
||||
- SKILLHUB_SECURITY_SCANNER_MODE=local
|
||||
- SKILLHUB_SECURITY_SCANNER_MODE=upload
|
||||
- SKILLHUB_SCANNER_USE_BEHAVIORAL=false
|
||||
- SKILLHUB_SCANNER_USE_LLM=false
|
||||
- SKILLHUB_SCANNER_USE_META=true
|
||||
|
|
@ -348,9 +348,9 @@ stringData:
|
|||
skillhub:
|
||||
security:
|
||||
scanner:
|
||||
enabled: false # 开发时禁用扫描,加快迭代速度
|
||||
enabled: true # 默认启用;PUBLIC/NAMESPACE_ONLY 发布依赖扫描
|
||||
base-url: http://localhost:8000
|
||||
mode: local
|
||||
mode: upload
|
||||
analyzers:
|
||||
meta: true # 只启用元数据分析
|
||||
policy:
|
||||
|
|
@ -366,7 +366,7 @@ skillhub:
|
|||
scanner:
|
||||
enabled: true # 测试环境启用扫描
|
||||
base-url: http://skill-scanner:8000
|
||||
mode: local
|
||||
mode: upload
|
||||
analyzers:
|
||||
behavioral: true
|
||||
meta: true
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
# Release entrypoint for the SkillHub CLI.
|
||||
#
|
||||
# This script bumps cli/package.json, commits the bump, creates a `cli-vX.Y.Z`
|
||||
# tag, and pushes it. The GitHub Actions workflow `release-cli.yml` picks up
|
||||
# the tag and performs the actual build + npm publish + GitHub Release.
|
||||
#
|
||||
# Prefer this over direct `npm publish`: avoids local network/TLS issues with
|
||||
# registry.npmjs.org, and keeps release provenance tied to CI.
|
||||
# Runs local build-and-test (lint, typecheck, test, build), bumps the version
|
||||
# in cli/package.json, pushes a release branch, and opens a PR to main.
|
||||
# Tagging is done manually after the PR is merged — the tag push triggers
|
||||
# `release-cli.yml` which builds and publishes to npm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -36,6 +34,87 @@ if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "maj
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# --- Cleanup state machine ---
|
||||
#
|
||||
# Tracks how far we got so the ERR trap can roll back the right pieces.
|
||||
# Stages advance monotonically; the trap inspects this to decide what to undo.
|
||||
#
|
||||
# pre-branch — nothing created yet
|
||||
# on-release — checked out release branch (may have uncommitted edits)
|
||||
# committed — version bump committed locally, not yet pushed
|
||||
# pushed — release branch pushed to origin (PR not yet open)
|
||||
# pr-opened — PR opened (terminal success state, trap is a no-op)
|
||||
#
|
||||
CLEANUP_STAGE="pre-branch"
|
||||
ORIGINAL_BRANCH=""
|
||||
RELEASE_BRANCH=""
|
||||
|
||||
cleanup_on_error() {
|
||||
local exit_code=$?
|
||||
trap - ERR EXIT INT TERM
|
||||
set +e
|
||||
|
||||
# When triggered by a signal with no failed command, $? may be 0; force a
|
||||
# non-zero exit so the caller sees the interruption.
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
exit_code=130
|
||||
fi
|
||||
|
||||
case "$CLEANUP_STAGE" in
|
||||
pre-branch)
|
||||
# build-and-test runs `bun run lint/typecheck/build`, all of which
|
||||
# regenerate cli/src/generated/pkg-info.ts via their pre-* hooks. If
|
||||
# we got interrupted mid-flight, restore it so the working tree is
|
||||
# clean for the next attempt.
|
||||
git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts" 2>/dev/null
|
||||
;;
|
||||
pr-opened)
|
||||
# Already succeeded.
|
||||
;;
|
||||
on-release)
|
||||
echo "" >&2
|
||||
echo "[publish-cli] error before commit — rolling back release branch" >&2
|
||||
git -C "$REPO_ROOT" checkout -f "$ORIGINAL_BRANCH" 2>/dev/null
|
||||
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null
|
||||
;;
|
||||
committed)
|
||||
echo "" >&2
|
||||
echo "[publish-cli] error after commit but before push — rolling back local branch" >&2
|
||||
git -C "$REPO_ROOT" checkout -f "$ORIGINAL_BRANCH" 2>/dev/null
|
||||
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null
|
||||
;;
|
||||
pushed)
|
||||
echo "" >&2
|
||||
echo "ERROR: release branch '$RELEASE_BRANCH' was pushed but PR creation failed." >&2
|
||||
echo "" >&2
|
||||
echo "To recover:" >&2
|
||||
echo "" >&2
|
||||
echo " 1. Open the PR manually:" >&2
|
||||
echo " gh pr create --base main --head $RELEASE_BRANCH" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Or roll back:" >&2
|
||||
echo " git checkout $ORIGINAL_BRANCH" >&2
|
||||
echo " git branch -D $RELEASE_BRANCH" >&2
|
||||
echo " git push origin --delete $RELEASE_BRANCH" >&2
|
||||
echo "" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap cleanup_on_error ERR INT TERM
|
||||
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "gh CLI is required (https://cli.github.com)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
echo "gh CLI is not authenticated. Run: gh auth login" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_stage "checking git working tree"
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
|
||||
echo "git working tree is not clean — commit or stash changes first" >&2
|
||||
|
|
@ -47,124 +126,166 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
|||
echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
ORIGINAL_BRANCH="$CURRENT_BRANCH"
|
||||
|
||||
log_stage "pulling latest from origin/$CURRENT_BRANCH"
|
||||
git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH"
|
||||
|
||||
log_stage "fetching tags from origin"
|
||||
git -C "$REPO_ROOT" fetch --tags --prune origin
|
||||
# --- Compute version & pre-flight checks (cheap, run before build) ---
|
||||
#
|
||||
# Baseline is read from origin via `ls-remote`, not from local tags. A local
|
||||
# orphan tag left over from a failed `git push origin cli-vX.Y.Z` must not
|
||||
# influence the next version — otherwise we'd skip versions or release on top
|
||||
# of something that was never published.
|
||||
|
||||
log_stage "checking for unpushed release artifacts"
|
||||
UNPUSHED_COMMITS="$(git -C "$REPO_ROOT" log --oneline origin/"$CURRENT_BRANCH"..HEAD 2>/dev/null || true)"
|
||||
|
||||
# Detect local cli-v* tags that don't exist on origin.
|
||||
# This catches both: (a) commit not pushed + tag not pushed, and
|
||||
# (b) commit pushed but tag push failed (where --no-merged would miss it).
|
||||
UNPUSHED_RELEASE_TAGS=""
|
||||
while IFS= read -r local_tag; do
|
||||
[[ -z "$local_tag" ]] && continue
|
||||
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$local_tag" >/dev/null 2>&1; then
|
||||
UNPUSHED_RELEASE_TAGS="${UNPUSHED_RELEASE_TAGS:+$UNPUSHED_RELEASE_TAGS
|
||||
}$local_tag"
|
||||
fi
|
||||
done < <(git -C "$REPO_ROOT" tag --list 'cli-v*' 2>/dev/null)
|
||||
|
||||
if [[ -n "$UNPUSHED_COMMITS" ]] || [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
|
||||
echo "" >&2
|
||||
echo "ERROR: Detected unpushed release artifacts from a previous failed push:" >&2
|
||||
echo "" >&2
|
||||
|
||||
if [[ -n "$UNPUSHED_COMMITS" ]]; then
|
||||
echo " Unpushed commits:" >&2
|
||||
echo "$UNPUSHED_COMMITS" | sed 's/^/ /' >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
|
||||
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
|
||||
echo " Unpushed tags:" >&2
|
||||
echo "$UNPUSHED_RELEASE_TAGS" | sed 's/^/ /' >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
|
||||
echo "Choose a recovery option:" >&2
|
||||
echo "" >&2
|
||||
echo " 1. Retry push (if the previous failure was temporary, e.g., network issue):" >&2
|
||||
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
|
||||
FIRST_TAG="$(echo "$UNPUSHED_RELEASE_TAGS" | head -n1)"
|
||||
echo " git push origin $CURRENT_BRANCH $FIRST_TAG" >&2
|
||||
else
|
||||
echo " git push origin $CURRENT_BRANCH" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo " 2. Rollback and retry release (if you want to start fresh):" >&2
|
||||
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
|
||||
echo " git tag -d $UNPUSHED_RELEASE_TAGS" | tr '\n' ' ' | sed 's/ $/\n/' >&2
|
||||
fi
|
||||
echo " git reset --hard origin/$CURRENT_BRANCH" >&2
|
||||
echo " # Then re-run: make publish-cli" >&2
|
||||
echo "" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_stage "resolving baseline version from latest cli-v* tag"
|
||||
LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'cli-v*' --sort=-version:refname | head -n1)"
|
||||
log_stage "computing next version from latest origin cli-v* tag"
|
||||
LATEST_TAG="$(git -C "$REPO_ROOT" ls-remote --tags --refs origin 'cli-v*' \
|
||||
| awk '{sub(/^refs\/tags\//, "", $2); print $2}' \
|
||||
| sort -V \
|
||||
| tail -n1)"
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
BASE_VERSION="${LATEST_TAG#cli-v}"
|
||||
CURRENT_PKG_VERSION="$(node -p "require('$PACKAGE_JSON').version")"
|
||||
if [[ "$BASE_VERSION" != "$CURRENT_PKG_VERSION" ]]; then
|
||||
log_stage "syncing package.json $CURRENT_PKG_VERSION -> $BASE_VERSION (from $LATEST_TAG)"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('$PACKAGE_JSON', 'utf8'));
|
||||
pkg.version = '$BASE_VERSION';
|
||||
fs.writeFileSync('$PACKAGE_JSON', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
# Reject prerelease tags (e.g., cli-v0.2.0-rc.1). Only pure X.Y.Z is supported.
|
||||
if [[ "$BASE_VERSION" =~ [^0-9.] ]]; then
|
||||
echo "latest origin tag $LATEST_TAG contains prerelease suffix: $BASE_VERSION" >&2
|
||||
echo "this script only supports pure X.Y.Z versions" >&2
|
||||
echo "skip prerelease tags manually or use a different baseline" >&2
|
||||
exit 1
|
||||
fi
|
||||
log_stage "baseline: $BASE_VERSION (from origin $LATEST_TAG)"
|
||||
else
|
||||
log_stage "no cli-v* tags found, bumping from package.json"
|
||||
BASE_VERSION="$(PACKAGE_JSON="$PACKAGE_JSON" node -p "require(process.env.PACKAGE_JSON).version")"
|
||||
log_stage "no cli-v* tags on origin, baseline: $BASE_VERSION (from package.json)"
|
||||
fi
|
||||
|
||||
log_stage "bumping version ($BUMP_TYPE)"
|
||||
NPM_VERSION_OUTPUT="$(cd "$CLI_DIR" && npm version "$BUMP_TYPE" --no-git-tag-version)"
|
||||
NEW_VERSION="${NPM_VERSION_OUTPUT#v}"
|
||||
NEW_VERSION="$(BASE_VERSION="$BASE_VERSION" BUMP_TYPE="$BUMP_TYPE" node -e "
|
||||
const v = process.env.BASE_VERSION.split('.').map(Number);
|
||||
if (v.length !== 3 || v.some(Number.isNaN)) {
|
||||
console.error('invalid baseline version: ' + process.env.BASE_VERSION);
|
||||
process.exit(1);
|
||||
}
|
||||
const t = process.env.BUMP_TYPE;
|
||||
if (t === 'patch') v[2]++;
|
||||
else if (t === 'minor') { v[1]++; v[2] = 0; }
|
||||
else if (t === 'major') { v[0]++; v[1] = 0; v[2] = 0; }
|
||||
console.log(v.join('.'));
|
||||
")"
|
||||
|
||||
if [[ -z "$NEW_VERSION" ]]; then
|
||||
echo "failed to parse version from npm output: $NPM_VERSION_OUTPUT" >&2
|
||||
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
|
||||
echo "failed to compute new version from baseline $BASE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="cli-v${NEW_VERSION}"
|
||||
RELEASE_BRANCH="release/${TAG}"
|
||||
|
||||
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
echo "tag $TAG already exists locally" >&2
|
||||
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "tag $TAG already exists on origin" >&2
|
||||
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_stage "new version: $NEW_VERSION (tag: $TAG)"
|
||||
|
||||
if ! confirm "Commit, tag, and push $TAG to origin?"; then
|
||||
echo "release cancelled — reverting package.json" >&2
|
||||
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
|
||||
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/heads/$RELEASE_BRANCH" >/dev/null; then
|
||||
echo "branch $RELEASE_BRANCH already exists locally" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "refs/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then
|
||||
echo "branch $RELEASE_BRANCH already exists on origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_stage "target: $NEW_VERSION (branch: $RELEASE_BRANCH)"
|
||||
|
||||
# --- Local build-and-test ---
|
||||
|
||||
log_stage "installing dependencies"
|
||||
(cd "$CLI_DIR" && bun install --frozen-lockfile)
|
||||
|
||||
log_stage "running lint"
|
||||
(cd "$CLI_DIR" && bun run lint)
|
||||
|
||||
log_stage "running typecheck"
|
||||
(cd "$CLI_DIR" && bun run typecheck)
|
||||
|
||||
log_stage "running tests"
|
||||
(cd "$CLI_DIR" && bun test)
|
||||
|
||||
log_stage "running build"
|
||||
(cd "$CLI_DIR" && bun run build)
|
||||
|
||||
log_stage "build-and-test passed"
|
||||
|
||||
# Reset only the codegen file that build-and-test regenerates. We rewrite
|
||||
# pkg-info.ts again after the version bump, so this just keeps the working
|
||||
# tree clean before branching.
|
||||
git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts"
|
||||
|
||||
if ! confirm "Push $RELEASE_BRANCH and open PR to main?"; then
|
||||
echo "release cancelled" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create branch, bump, push, open PR ---
|
||||
|
||||
log_stage "creating release branch $RELEASE_BRANCH"
|
||||
git -C "$REPO_ROOT" checkout -b "$RELEASE_BRANCH"
|
||||
CLEANUP_STAGE="on-release"
|
||||
|
||||
log_stage "writing version $NEW_VERSION to package.json"
|
||||
NEW_VERSION="$NEW_VERSION" PACKAGE_JSON="$PACKAGE_JSON" node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync(process.env.PACKAGE_JSON, 'utf8'));
|
||||
pkg.version = process.env.NEW_VERSION;
|
||||
fs.writeFileSync(process.env.PACKAGE_JSON, JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
log_stage "regenerating pkg-info.ts with new version"
|
||||
(cd "$CLI_DIR" && bun run scripts/generate-pkg-info.ts)
|
||||
|
||||
log_stage "committing version bump"
|
||||
git -C "$REPO_ROOT" add "$PACKAGE_JSON"
|
||||
git -C "$REPO_ROOT" add "$PACKAGE_JSON" "$CLI_DIR/src/generated/pkg-info.ts"
|
||||
git -C "$REPO_ROOT" commit -m "chore(cli): bump version to $NEW_VERSION"
|
||||
CLEANUP_STAGE="committed"
|
||||
|
||||
log_stage "creating tag $TAG"
|
||||
git -C "$REPO_ROOT" tag "$TAG"
|
||||
log_stage "pushing release branch to origin"
|
||||
git -C "$REPO_ROOT" push -u origin "$RELEASE_BRANCH"
|
||||
CLEANUP_STAGE="pushed"
|
||||
|
||||
log_stage "pushing commit and tag to origin (atomic)"
|
||||
git -C "$REPO_ROOT" push --atomic origin "$CURRENT_BRANCH" "$TAG"
|
||||
log_stage "opening pull request"
|
||||
PR_BODY="Bumps CLI version to \`$NEW_VERSION\`.
|
||||
|
||||
log_stage "release triggered — CI workflow will build and publish"
|
||||
log_stage "watch progress at: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml"
|
||||
Local build-and-test passed (lint, typecheck, test, build).
|
||||
|
||||
After merging, tag and push to trigger the release:
|
||||
\`\`\`bash
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git merge --ff-only origin/main
|
||||
git tag $TAG origin/main
|
||||
git push origin $TAG
|
||||
\`\`\`
|
||||
|
||||
Watch the release: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml"
|
||||
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--title "chore(cli): release $NEW_VERSION" \
|
||||
--body "$PR_BODY"
|
||||
CLEANUP_STAGE="pr-opened"
|
||||
|
||||
log_stage "returning to $CURRENT_BRANCH"
|
||||
git -C "$REPO_ROOT" checkout "$CURRENT_BRANCH"
|
||||
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH"
|
||||
|
||||
log_stage "done — PR opened. After merge, tag manually:"
|
||||
echo ""
|
||||
echo " git fetch origin main"
|
||||
echo " git tag $TAG origin/main"
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -159,13 +159,34 @@ set_env_value() {
|
|||
fi
|
||||
|
||||
tmp="$ENV_FILE.tmp"
|
||||
if grep -q "^$key=" "$ENV_FILE"; then
|
||||
sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp"
|
||||
else
|
||||
cat "$ENV_FILE" >"$tmp"
|
||||
printf '%s=%s\n' "$key" "$value" >>"$tmp"
|
||||
fi
|
||||
found=false
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
{
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
"$key="*)
|
||||
printf '%s=%s\n' "$key" "$value"
|
||||
found=true
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$line"
|
||||
;;
|
||||
esac
|
||||
done <"$ENV_FILE"
|
||||
if [ "$found" = "false" ]; then
|
||||
printf '%s=%s\n' "$key" "$value"
|
||||
fi
|
||||
} >"$tmp"
|
||||
umask "$old_umask"
|
||||
mv "$tmp" "$ENV_FILE"
|
||||
secure_env_file
|
||||
}
|
||||
|
||||
secure_env_file() {
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
get_env_value() {
|
||||
|
|
@ -180,6 +201,42 @@ get_env_value() {
|
|||
fi
|
||||
}
|
||||
|
||||
generate_secret() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
|
||||
dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_placeholder_secret() {
|
||||
case "$1" in
|
||||
""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_anonymous_download_secret() {
|
||||
secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")"
|
||||
if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)"
|
||||
echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE"
|
||||
}
|
||||
|
||||
wait_for_postgres_ready() {
|
||||
postgres_user="$1"
|
||||
postgres_db="$2"
|
||||
|
|
@ -199,6 +256,23 @@ wait_for_postgres_ready() {
|
|||
exit 1
|
||||
}
|
||||
|
||||
wait_for_redis_ready() {
|
||||
attempt=1
|
||||
|
||||
while [ "$attempt" -le 60 ]; do
|
||||
if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Redis did not become ready in time." >&2
|
||||
run_compose logs redis >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_postgres_password_matches_env() {
|
||||
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
|
||||
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
|
||||
|
|
@ -231,8 +305,12 @@ prepare_runtime_files() {
|
|||
download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
umask "$old_umask"
|
||||
fi
|
||||
secure_env_file
|
||||
|
||||
if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then
|
||||
mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}"
|
||||
|
|
@ -280,6 +358,12 @@ prepare_runtime_files() {
|
|||
if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then
|
||||
set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE"
|
||||
fi
|
||||
|
||||
if [ "$DISABLE_SCANNER" = "true" ]; then
|
||||
set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false"
|
||||
fi
|
||||
|
||||
ensure_anonymous_download_secret
|
||||
}
|
||||
|
||||
run_compose() {
|
||||
|
|
@ -295,7 +379,9 @@ case "$COMMAND" in
|
|||
run_compose up -d postgres
|
||||
ensure_postgres_password_matches_env
|
||||
if [ "$DISABLE_SCANNER" = "true" ]; then
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
|
||||
run_compose up -d redis
|
||||
wait_for_redis_ready
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web
|
||||
else
|
||||
run_compose up -d
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ echo "Target: $BASE_URL"
|
|||
echo
|
||||
|
||||
check "Health endpoint" "$BASE_URL/actuator/health" "200"
|
||||
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
|
||||
check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200"
|
||||
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
|
||||
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
|
||||
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
|
||||
|
||||
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
|
||||
|
|
@ -67,6 +67,15 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)"
|
||||
if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then
|
||||
echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/v1/auth/local/change-password" \
|
||||
-b "$COOKIE_JAR" \
|
||||
|
|
|
|||
25
scripts/tests/dev-web-host-test.sh
Executable file
25
scripts/tests/dev-web-host-test.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
MAKEFILE="$REPO_ROOT/Makefile"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \
|
||||
|| fail "Makefile must default DEV_WEB_HOST to 127.0.0.1"
|
||||
|
||||
grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \
|
||||
|| fail "dev web startup must pass DEV_WEB_HOST to vite"
|
||||
|
||||
grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|
||||
|| fail "dev-server must inject scanner upload environment"
|
||||
|
||||
if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then
|
||||
fail "dev web startup must not bind Vite to 0.0.0.0 by default"
|
||||
fi
|
||||
|
||||
echo "dev-web-host-test passed"
|
||||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
# Integration tests for scripts/publish-cli.sh.
|
||||
#
|
||||
# The script bumps cli/package.json, commits, tags `cli-vX.Y.Z`, and pushes
|
||||
# both refs to origin. These tests build a self-contained fake repo for each
|
||||
# scenario, using a real bare repository as origin so git fetch / pull / push
|
||||
# are actually exercised. `npm` is stubbed to keep `npm version` deterministic.
|
||||
# The script runs local build-and-test (lint, typecheck, test, build), bumps
|
||||
# cli/package.json, pushes a `release/cli-vX.Y.Z` branch, and opens a PR via
|
||||
# `gh pr create`. These tests build a self-contained fake repo per scenario,
|
||||
# using a real bare repository as origin so git fetch/pull/push are actually
|
||||
# exercised. `bun` and `gh` are stubbed to keep the heavy steps deterministic.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -37,8 +38,10 @@ fail() {
|
|||
#
|
||||
# Builds a minimal repo with:
|
||||
# - cli/package.json at the requested version
|
||||
# - cli/src/generated/pkg-info.ts (committed, so script's checkout works)
|
||||
# - scripts/publish-cli.sh (the script under test)
|
||||
# - bin/npm stub that implements `npm version <bump> --no-git-tag-version`
|
||||
# - bin/bun stub: handles install / run lint|typecheck|build|<codegen> / test
|
||||
# - bin/gh stub: handles `auth status` and `pr create` (logs to gh-stub.log)
|
||||
# - a sibling bare repo as `origin`
|
||||
# - HEAD on `main` with the init commit already pushed
|
||||
# - optional pre-seeded `cli-v*` tags (created locally AND on origin)
|
||||
|
|
@ -53,7 +56,7 @@ init_repo() {
|
|||
local origin="$repo.origin.git"
|
||||
TMP_DIRS+=("$origin")
|
||||
|
||||
mkdir -p "$repo/cli" "$repo/scripts" "$repo/bin"
|
||||
mkdir -p "$repo/cli/src/generated" "$repo/scripts" "$repo/bin"
|
||||
cp "$PUBLISH_SCRIPT" "$repo/scripts/publish-cli.sh"
|
||||
|
||||
cat >"$repo/cli/package.json" <<EOF
|
||||
|
|
@ -66,40 +69,103 @@ init_repo() {
|
|||
}
|
||||
EOF
|
||||
|
||||
# Stub `npm`: only `npm version <patch|minor|major> --no-git-tag-version` is
|
||||
# supported. Mutates package.json in cwd and echoes `vX.Y.Z` (matching real
|
||||
# npm behaviour the script depends on).
|
||||
cat >"$repo/bin/npm" <<'EOF'
|
||||
cat >"$repo/cli/src/generated/pkg-info.ts" <<EOF
|
||||
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
|
||||
export const PKG_NAME = "@astron-team/skillhub"
|
||||
export const PKG_VERSION = "$version"
|
||||
EOF
|
||||
|
||||
# Stub `bun`: handles every subcommand the publish script invokes.
|
||||
# Failure injection via BUN_FAIL_AT (substring match against full args).
|
||||
cat >"$repo/bin/bun" <<'BUN_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" != "version" ]]; then
|
||||
echo "npm stub: unsupported subcommand: $*" >&2
|
||||
|
||||
if [[ -n "${BUN_FAIL_AT:-}" ]] && [[ "$*" == *"$BUN_FAIL_AT"* ]]; then
|
||||
echo "stub bun: forced failure at: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
BUMP="$2"
|
||||
node - "$PWD/package.json" "$BUMP" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const path = process.argv[2];
|
||||
const bump = process.argv[3];
|
||||
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const parts = pkg.version.split(".").map(Number);
|
||||
if (bump === "patch") parts[2] += 1;
|
||||
else if (bump === "minor") { parts[1] += 1; parts[2] = 0; }
|
||||
else if (bump === "major") { parts[0] += 1; parts[1] = 0; parts[2] = 0; }
|
||||
else throw new Error("unexpected bump: " + bump);
|
||||
const next = parts.join(".");
|
||||
pkg.version = next;
|
||||
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
|
||||
console.log("v" + next);
|
||||
NODE
|
||||
EOF
|
||||
chmod +x "$repo/bin/npm"
|
||||
|
||||
# Ignore test scaffolding files so they don't make `git status` dirty.
|
||||
case "${1:-}" in
|
||||
install)
|
||||
exit 0
|
||||
;;
|
||||
test)
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
case "${2:-}" in
|
||||
lint|typecheck|build)
|
||||
exit 0
|
||||
;;
|
||||
scripts/generate-pkg-info.ts)
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cliRoot = process.cwd();
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cliRoot, 'package.json'), 'utf8'));
|
||||
const out = path.join(cliRoot, 'src/generated/pkg-info.ts');
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out,
|
||||
'// Generated by scripts/generate-pkg-info.ts - do not edit by hand.\n' +
|
||||
'export const PKG_NAME = ' + JSON.stringify(pkg.name) + '\n' +
|
||||
'export const PKG_VERSION = ' + JSON.stringify(pkg.version) + '\n');
|
||||
"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "stub bun: unsupported run target: ${2:-}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "stub bun: unsupported subcommand: ${1:-}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
BUN_EOF
|
||||
chmod +x "$repo/bin/bun"
|
||||
|
||||
# Stub `gh`: handles `auth status` (always 0) and `pr create` (logs args).
|
||||
# Failure injection via GH_FAIL_AT="auth"|"pr-create".
|
||||
cat >"$repo/bin/gh" <<'GH_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${GH_FAIL_AT:-}" ]]; then
|
||||
if [[ "$GH_FAIL_AT" == "auth" && "${1:-}" == "auth" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$GH_FAIL_AT" == "pr-create" && "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
|
||||
echo "stub gh: forced PR create failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
auth)
|
||||
exit 0
|
||||
;;
|
||||
pr)
|
||||
if [[ "${2:-}" == "create" ]]; then
|
||||
printf '%s\n' "$*" >> "${GH_LOG_FILE:-/dev/null}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "stub gh: unsupported: ${1:-}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
GH_EOF
|
||||
chmod +x "$repo/bin/gh"
|
||||
|
||||
cat >"$repo/.gitignore" <<EOF
|
||||
stdout.log
|
||||
stderr.log
|
||||
git-push-log.txt
|
||||
gh-stub.log
|
||||
bin-git/
|
||||
EOF
|
||||
|
||||
|
|
@ -108,7 +174,8 @@ EOF
|
|||
git -C "$repo" config user.name "Test User"
|
||||
git -C "$repo" config user.email "test@example.com"
|
||||
git -C "$repo" remote add origin "$origin"
|
||||
git -C "$repo" add cli/package.json scripts/publish-cli.sh bin/npm .gitignore
|
||||
git -C "$repo" add cli/package.json cli/src/generated/pkg-info.ts \
|
||||
scripts/publish-cli.sh bin/bun bin/gh .gitignore
|
||||
git -C "$repo" commit -q -m "init"
|
||||
git -C "$repo" push -q -u origin main
|
||||
|
||||
|
|
@ -119,24 +186,32 @@ EOF
|
|||
done
|
||||
}
|
||||
|
||||
# run_publish <repo> <bump> [stdin]
|
||||
# Writes stdout to $repo/stdout.log and stderr to $repo/stderr.log.
|
||||
# Prints the exit code on stdout.
|
||||
# run_publish <repo> <bump> [stdin] [extra_env=...]
|
||||
#
|
||||
# Writes stdout/stderr to $repo/{stdout,stderr}.log and prints exit code.
|
||||
# `extra_env` (e.g. "BUN_FAIL_AT=lint") is forwarded as bash env assignments.
|
||||
run_publish() {
|
||||
local repo="$1"
|
||||
local bump="$2"
|
||||
local input="${3-}"
|
||||
local extra="${4-}"
|
||||
local status=0
|
||||
local cmd=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE
|
||||
REPO_ROOT="$repo" PATH="$repo/bin:$PATH"
|
||||
GH_LOG_FILE="$repo/gh-stub.log")
|
||||
if [[ -n "$extra" ]]; then
|
||||
# Split "K1=V1 K2=V2" into separate env entries.
|
||||
local kv
|
||||
for kv in $extra; do
|
||||
cmd+=("$kv")
|
||||
done
|
||||
fi
|
||||
cmd+=(bash "$repo/scripts/publish-cli.sh" "$bump")
|
||||
|
||||
if [[ -n "$input" ]]; then
|
||||
printf '%s' "$input" | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
|
||||
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
|
||||
bash "$repo/scripts/publish-cli.sh" "$bump" \
|
||||
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
|
||||
printf '%s' "$input" | "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
|
||||
else
|
||||
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
|
||||
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
|
||||
bash "$repo/scripts/publish-cli.sh" "$bump" \
|
||||
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
|
||||
"${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
|
||||
fi
|
||||
echo "$status"
|
||||
}
|
||||
|
|
@ -157,10 +232,9 @@ grep -F "Usage:" "$REPO1/stderr.log" >/dev/null
|
|||
echo "[test] dirty working tree aborts"
|
||||
REPO2="$(new_tmp)"
|
||||
init_repo "$REPO2"
|
||||
touch "$REPO2/dirty.txt"
|
||||
echo "junk" > "$REPO2/cli/package.json"
|
||||
status="$(run_publish "$REPO2" "patch")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit for dirty tree"
|
||||
grep -F "checking git working tree" "$REPO2/stdout.log" >/dev/null
|
||||
grep -F "git working tree is not clean" "$REPO2/stderr.log" >/dev/null
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
|
|
@ -176,149 +250,135 @@ grep -F "releases must be cut from 'main'" "$REPO3/stderr.log" >/dev/null
|
|||
grep -F "feature/x" "$REPO3/stderr.log" >/dev/null
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 4: package.json behind latest cli-v* tag → baseline sync, then bump
|
||||
# Test 4: gh auth fails → abort early
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] baseline sync from latest cli-v* tag, then bump + push"
|
||||
echo "[test] gh auth failure aborts"
|
||||
REPO4="$(new_tmp)"
|
||||
init_repo "$REPO4" "0.1.0" "cli-v0.2.0"
|
||||
status="$(run_publish "$REPO4" "patch" $'y\n')"
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO4/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
grep -F "syncing package.json 0.1.0 -> 0.2.0 (from cli-v0.2.0)" "$REPO4/stdout.log" >/dev/null
|
||||
grep -F "bumping version (patch)" "$REPO4/stdout.log" >/dev/null
|
||||
grep -F "new version: 0.2.1 (tag: cli-v0.2.1)" "$REPO4/stdout.log" >/dev/null
|
||||
grep -F '"version": "0.2.1"' "$REPO4/cli/package.json" >/dev/null
|
||||
git -C "$REPO4" rev-parse "cli-v0.2.1" >/dev/null \
|
||||
|| fail "local tag cli-v0.2.1 missing"
|
||||
git -C "$REPO4" log --oneline | grep -F "chore(cli): bump version to 0.2.1" >/dev/null
|
||||
git -C "$REPO4.origin.git" rev-parse "cli-v0.2.1" >/dev/null \
|
||||
|| fail "origin tag cli-v0.2.1 missing — atomic push not delivered"
|
||||
init_repo "$REPO4"
|
||||
status="$(run_publish "$REPO4" "patch" "" "GH_FAIL_AT=auth")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh auth fails"
|
||||
grep -F "gh CLI is not authenticated" "$REPO4/stderr.log" >/dev/null
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 5: no cli-v* tags → fall back to package.json
|
||||
# Test 5: release branch already exists → abort before build (fail-fast)
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] no cli-v* tags falls back to package.json"
|
||||
echo "[test] existing release branch aborts before build"
|
||||
REPO5="$(new_tmp)"
|
||||
init_repo "$REPO5" "0.1.0"
|
||||
status="$(run_publish "$REPO5" "minor" $'y\n')"
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO5/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
grep -F "no cli-v* tags found, bumping from package.json" "$REPO5/stdout.log" >/dev/null
|
||||
grep -F "new version: 0.2.0 (tag: cli-v0.2.0)" "$REPO5/stdout.log" >/dev/null
|
||||
git -C "$REPO5.origin.git" rev-parse "cli-v0.2.0" >/dev/null \
|
||||
|| fail "origin tag cli-v0.2.0 missing"
|
||||
init_repo "$REPO5" "0.1.0" "cli-v0.1.0"
|
||||
# Baseline is cli-v0.1.0, patch target is cli-v0.1.1, branch=release/cli-v0.1.1.
|
||||
# Create that branch locally so the pre-flight check catches it.
|
||||
git -C "$REPO5" branch "release/cli-v0.1.1"
|
||||
status="$(run_publish "$REPO5" "patch")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when release branch exists"
|
||||
grep -F "branch release/cli-v0.1.1 already exists locally" "$REPO5/stderr.log" >/dev/null \
|
||||
|| { cat "$REPO5/stderr.log" >&2; fail "expected 'branch already exists locally' error"; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 6: confirmation cancel → revert package.json, no commit, no tag
|
||||
# Test 6: confirmation cancel → no branch, no commit, no push
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] confirmation cancel reverts everything"
|
||||
echo "[test] confirmation cancel leaves no side effects"
|
||||
REPO6="$(new_tmp)"
|
||||
init_repo "$REPO6" "0.1.0" "cli-v0.1.0"
|
||||
INITIAL_HEAD="$(git -C "$REPO6" rev-parse HEAD)"
|
||||
status="$(run_publish "$REPO6" "patch" $'n\n')"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit on cancel"
|
||||
grep -F "release cancelled" "$REPO6/stderr.log" >/dev/null
|
||||
grep -F '"version": "0.1.0"' "$REPO6/cli/package.json" >/dev/null \
|
||||
|| fail "package.json not reverted to 0.1.0 after cancel"
|
||||
[[ "$(git -C "$REPO6" rev-parse HEAD)" == "$INITIAL_HEAD" ]] \
|
||||
|| fail "HEAD advanced after cancel — extra commit was made"
|
||||
if git -C "$REPO6" rev-parse -q --verify "refs/tags/cli-v0.1.1" >/dev/null 2>&1; then
|
||||
fail "tag cli-v0.1.1 must not exist after cancel"
|
||||
|| fail "HEAD advanced after cancel"
|
||||
[[ "$(git -C "$REPO6" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|
||||
|| fail "not on main after cancel"
|
||||
if git -C "$REPO6" rev-parse -q --verify "refs/heads/release/cli-v0.1.1" >/dev/null 2>&1; then
|
||||
fail "release branch must not exist after cancel"
|
||||
fi
|
||||
[[ -z "$(git -C "$REPO6" status --porcelain)" ]] \
|
||||
|| fail "working tree not clean after cancel — revert incomplete"
|
||||
|| fail "working tree not clean after cancel"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 7: happy path — atomic push delivers branch + tag together
|
||||
# Test 7: happy path — branch pushed, PR opened, returned to main
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] happy path pushes branch and tag atomically"
|
||||
echo "[test] happy path pushes branch and opens PR"
|
||||
REPO7="$(new_tmp)"
|
||||
init_repo "$REPO7" "0.5.0"
|
||||
status="$(run_publish "$REPO7" "patch" $'y\n')"
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO7/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
ORIGIN7="$REPO7.origin.git"
|
||||
git -C "$ORIGIN7" rev-parse "cli-v0.5.1" >/dev/null \
|
||||
|| fail "origin missing tag cli-v0.5.1"
|
||||
ORIGIN_HEAD="$(git -C "$ORIGIN7" rev-parse main)"
|
||||
LOCAL_HEAD="$(git -C "$REPO7" rev-parse main)"
|
||||
[[ "$ORIGIN_HEAD" == "$LOCAL_HEAD" ]] \
|
||||
|| fail "origin/main HEAD did not advance to match local main"
|
||||
TAG_COMMIT="$(git -C "$ORIGIN7" rev-parse "cli-v0.5.1^{commit}")"
|
||||
[[ "$TAG_COMMIT" == "$ORIGIN_HEAD" ]] \
|
||||
|| fail "origin tag cli-v0.5.1 does not point to origin/main HEAD"
|
||||
grep -F "release triggered" "$REPO7/stdout.log" >/dev/null
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 8: push failure → script exits non-zero (commit + tag stay local)
|
||||
#
|
||||
# Use a git wrapper that fails only on `git push`, so pull/fetch succeed
|
||||
# but the final push does not.
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] push failure surfaces error"
|
||||
REPO8="$(new_tmp)"
|
||||
init_repo "$REPO8" "0.6.0"
|
||||
mkdir -p "$REPO8/bin-git"
|
||||
cat >"$REPO8/bin-git/git" <<'WRAPPER'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "$*" == *"push"* ]]; then
|
||||
echo "fatal: could not read from remote repository." >&2
|
||||
exit 128
|
||||
# Returned to main with branch deleted locally.
|
||||
[[ "$(git -C "$REPO7" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|
||||
|| fail "not back on main after success"
|
||||
if git -C "$REPO7" rev-parse -q --verify "refs/heads/release/cli-v0.5.1" >/dev/null 2>&1; then
|
||||
fail "local release branch should be deleted after success"
|
||||
fi
|
||||
exec /usr/bin/git "$@"
|
||||
WRAPPER
|
||||
chmod +x "$REPO8/bin-git/git"
|
||||
status=0
|
||||
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
|
||||
REPO_ROOT="$REPO8" PATH="$REPO8/bin-git:$REPO8/bin:$PATH" \
|
||||
bash "$REPO8/scripts/publish-cli.sh" "patch" \
|
||||
>"$REPO8/stdout.log" 2>"$REPO8/stderr.log" || status=$?
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
|
||||
git -C "$REPO8" rev-parse "cli-v0.6.1" >/dev/null \
|
||||
|| fail "local tag cli-v0.6.1 missing after push failure"
|
||||
git -C "$REPO8" log --oneline | grep -F "chore(cli): bump version to 0.6.1" >/dev/null \
|
||||
|| fail "local bump commit missing after push failure"
|
||||
|
||||
# Branch on origin with bump commit.
|
||||
ORIGIN7="$REPO7.origin.git"
|
||||
git -C "$ORIGIN7" rev-parse "refs/heads/release/cli-v0.5.1" >/dev/null \
|
||||
|| fail "origin missing release branch"
|
||||
TIP_MSG="$(git -C "$ORIGIN7" log -1 --format=%s "refs/heads/release/cli-v0.5.1")"
|
||||
[[ "$TIP_MSG" == "chore(cli): bump version to 0.5.1" ]] \
|
||||
|| fail "unexpected commit message on release branch: $TIP_MSG"
|
||||
|
||||
# pkg-info.ts AND package.json both updated in the bump commit.
|
||||
git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/package.json" \
|
||||
| grep -F '"version": "0.5.1"' >/dev/null \
|
||||
|| fail "package.json on branch missing new version"
|
||||
git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/src/generated/pkg-info.ts" \
|
||||
| grep -F 'PKG_VERSION = "0.5.1"' >/dev/null \
|
||||
|| fail "pkg-info.ts on branch missing new version"
|
||||
|
||||
# gh pr create was invoked with expected args.
|
||||
[[ -f "$REPO7/gh-stub.log" ]] || fail "gh stub log missing"
|
||||
grep -F "pr create" "$REPO7/gh-stub.log" >/dev/null \
|
||||
|| fail "gh pr create not invoked"
|
||||
grep -F -- "--head release/cli-v0.5.1" "$REPO7/gh-stub.log" >/dev/null \
|
||||
|| fail "gh pr create missing --head"
|
||||
grep -F -- "--base main" "$REPO7/gh-stub.log" >/dev/null \
|
||||
|| fail "gh pr create missing --base"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 9: unpushed detection catches "branch pushed, tag not pushed" state
|
||||
#
|
||||
# Simulate: commit is on origin/main, local tag exists but was never pushed.
|
||||
# The old `--no-merged` approach would miss this because the tagged commit is
|
||||
# already reachable from origin/main. The new ls-remote approach catches it.
|
||||
# Test 8: baseline from latest cli-v* tag (not package.json)
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] unpushed detection catches tag-only failure"
|
||||
echo "[test] baseline taken from latest cli-v* tag"
|
||||
REPO8="$(new_tmp)"
|
||||
init_repo "$REPO8" "0.1.0" "cli-v0.2.0"
|
||||
status="$(run_publish "$REPO8" "patch" $'y\n')"
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO8/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
grep -F "baseline: 0.2.0 (from origin cli-v0.2.0)" "$REPO8/stdout.log" >/dev/null \
|
||||
|| fail "baseline log missing — version computed from wrong source"
|
||||
git -C "$REPO8.origin.git" rev-parse "refs/heads/release/cli-v0.2.1" >/dev/null \
|
||||
|| fail "expected branch release/cli-v0.2.1 on origin"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 9: gh pr create fails → branch stays on origin, recovery printed
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] gh pr create failure triggers pushed-stage cleanup"
|
||||
REPO9="$(new_tmp)"
|
||||
init_repo "$REPO9" "0.7.0"
|
||||
cd "$REPO9/cli"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
pkg.version = '0.7.1';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
cd "$REPO9"
|
||||
git -C "$REPO9" add cli/package.json
|
||||
git -C "$REPO9" commit -q -m "chore(cli): bump version to 0.7.1"
|
||||
git -C "$REPO9" tag "cli-v0.7.1"
|
||||
git -C "$REPO9" push -q origin main
|
||||
# Tag NOT pushed — simulates atomic push partial failure recovery
|
||||
status="$(run_publish "$REPO9" "patch")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when unpushed tag detected"
|
||||
grep -F "Unpushed tags" "$REPO9/stderr.log" >/dev/null \
|
||||
|| { cat "$REPO9/stderr.log" >&2; fail "expected unpushed tag warning"; }
|
||||
grep -F "cli-v0.7.1" "$REPO9/stderr.log" >/dev/null \
|
||||
|| fail "expected cli-v0.7.1 in unpushed tag warning"
|
||||
init_repo "$REPO9" "0.6.0"
|
||||
status="$(run_publish "$REPO9" "patch" $'y\n' "GH_FAIL_AT=pr-create")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh pr create fails"
|
||||
|
||||
# Recovery instructions emitted by the trap.
|
||||
grep -F "release branch 'release/cli-v0.6.1' was pushed but PR creation failed" \
|
||||
"$REPO9/stderr.log" >/dev/null \
|
||||
|| { cat "$REPO9/stderr.log" >&2; fail "missing pushed-stage recovery message"; }
|
||||
grep -F "git push origin --delete release/cli-v0.6.1" "$REPO9/stderr.log" >/dev/null \
|
||||
|| fail "missing rollback hint in recovery message"
|
||||
|
||||
# Branch is on origin (push succeeded before gh failed).
|
||||
git -C "$REPO9.origin.git" rev-parse "refs/heads/release/cli-v0.6.1" >/dev/null \
|
||||
|| fail "expected branch on origin after push, before failed PR"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 10: --atomic flag is actually passed to git push
|
||||
#
|
||||
# Use a git wrapper to capture the push command and verify --atomic is present.
|
||||
# Test 10: push fails → committed-stage cleanup, branch deleted locally
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] push uses --atomic flag"
|
||||
echo "[test] push failure rolls back local branch"
|
||||
REPO10="$(new_tmp)"
|
||||
init_repo "$REPO10" "0.8.0"
|
||||
init_repo "$REPO10" "0.7.0"
|
||||
mkdir -p "$REPO10/bin-git"
|
||||
cat >"$REPO10/bin-git/git" <<'WRAPPER'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "$*" == *"push"* ]]; then
|
||||
echo "GIT_PUSH_ARGS: $*" >> "$REPO_ROOT/git-push-log.txt"
|
||||
if [[ "${1:-}" == "-C" && "${3:-}" == "push" ]]; then
|
||||
echo "fatal: forced push failure" >&2
|
||||
exit 128
|
||||
fi
|
||||
exec /usr/bin/git "$@"
|
||||
WRAPPER
|
||||
|
|
@ -326,10 +386,59 @@ chmod +x "$REPO10/bin-git/git"
|
|||
status=0
|
||||
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
|
||||
REPO_ROOT="$REPO10" PATH="$REPO10/bin-git:$REPO10/bin:$PATH" \
|
||||
GH_LOG_FILE="$REPO10/gh-stub.log" \
|
||||
bash "$REPO10/scripts/publish-cli.sh" "patch" \
|
||||
>"$REPO10/stdout.log" 2>"$REPO10/stderr.log" || status=$?
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO10/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
grep -F -- "--atomic" "$REPO10/git-push-log.txt" >/dev/null \
|
||||
|| { cat "$REPO10/git-push-log.txt" >&2; fail "git push did not include --atomic flag"; }
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
|
||||
|
||||
grep -F "rolling back local branch" "$REPO10/stderr.log" >/dev/null \
|
||||
|| { cat "$REPO10/stderr.log" >&2; fail "missing committed-stage rollback message"; }
|
||||
|
||||
# Back on main, release branch deleted.
|
||||
[[ "$(git -C "$REPO10" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|
||||
|| fail "not on main after push failure cleanup"
|
||||
if git -C "$REPO10" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then
|
||||
fail "local release branch should be deleted after push failure"
|
||||
fi
|
||||
|
||||
# Origin must NOT have the branch (push was blocked).
|
||||
if git -C "$REPO10.origin.git" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then
|
||||
fail "origin should not have branch when push failed"
|
||||
fi
|
||||
|
||||
# gh pr create must NOT have run.
|
||||
if [[ -f "$REPO10/gh-stub.log" ]] && grep -F "pr create" "$REPO10/gh-stub.log" >/dev/null; then
|
||||
fail "gh pr create ran despite push failure"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 11: prerelease tag → abort with clear message
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "[test] prerelease tag aborts with message"
|
||||
REPO11="$(new_tmp)"
|
||||
init_repo "$REPO11" "0.1.0" "cli-v0.2.0-rc.1"
|
||||
status="$(run_publish "$REPO11" "patch")"
|
||||
[[ "$status" -ne 0 ]] || fail "expected non-zero exit for prerelease tag"
|
||||
grep -F "contains prerelease suffix" "$REPO11/stderr.log" >/dev/null \
|
||||
|| { cat "$REPO11/stderr.log" >&2; fail "expected prerelease rejection message"; }
|
||||
grep -F "only supports pure X.Y.Z" "$REPO11/stderr.log" >/dev/null \
|
||||
|| fail "expected X.Y.Z hint in error"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Test 12: local-only orphan tag must not influence baseline
|
||||
# ----------------------------------------------------------------------------
|
||||
# Simulates the failure mode where `git push origin cli-vX.Y.Z` failed after
|
||||
# `git tag cli-vX.Y.Z origin/main` succeeded locally. The orphan tag exists
|
||||
# locally but not on origin. Baseline must come from origin only.
|
||||
echo "[test] local-only orphan tag ignored for baseline"
|
||||
REPO12="$(new_tmp)"
|
||||
init_repo "$REPO12" "0.1.0" "cli-v0.1.0"
|
||||
git -C "$REPO12" tag "cli-v0.3.0"
|
||||
status="$(run_publish "$REPO12" "patch" $'y\n')"
|
||||
[[ "$status" -eq 0 ]] || { cat "$REPO12/stderr.log" >&2; fail "expected success, got $status"; }
|
||||
grep -F "baseline: 0.1.0 (from origin cli-v0.1.0)" "$REPO12/stdout.log" >/dev/null \
|
||||
|| { cat "$REPO12/stdout.log" >&2; fail "baseline must come from origin (0.1.0), not local orphan (0.3.0)"; }
|
||||
git -C "$REPO12.origin.git" rev-parse "refs/heads/release/cli-v0.1.1" >/dev/null \
|
||||
|| fail "expected branch release/cli-v0.1.1 on origin (orphan should not have shifted target to 0.3.1)"
|
||||
|
||||
echo "all tests passed"
|
||||
|
|
|
|||
123
scripts/tests/runtime-secret-test.sh
Executable file
123
scripts/tests/runtime-secret-test.sh
Executable file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/runtime.sh"
|
||||
|
||||
TMP_DIRS=()
|
||||
cleanup() {
|
||||
local d
|
||||
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
new_tmp() {
|
||||
local d
|
||||
d="$(mktemp -d)"
|
||||
TMP_DIRS+=("$d")
|
||||
echo "$d"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
install_fake_tools() {
|
||||
local bin_dir="$1"
|
||||
mkdir -p "$bin_dir"
|
||||
cat >"$bin_dir/docker" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}"
|
||||
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$bin_dir/docker"
|
||||
|
||||
cat >"$bin_dir/openssl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then
|
||||
printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n'
|
||||
exit 0
|
||||
fi
|
||||
echo "unsupported openssl args: $*" >&2
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$bin_dir/openssl"
|
||||
}
|
||||
|
||||
run_runtime() {
|
||||
local home="$1"
|
||||
local bin_dir="$2"
|
||||
local stdout="$3"
|
||||
shift 3
|
||||
DOCKER_LOG="$home/docker.log" \
|
||||
SKILLHUB_HOME="$home" \
|
||||
SKILLHUB_RAW_BASE="file://$REPO_ROOT" \
|
||||
PATH="$bin_dir:$PATH" \
|
||||
sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout"
|
||||
}
|
||||
|
||||
file_mode() {
|
||||
local file="$1"
|
||||
if stat -c %a "$file" >/dev/null 2>&1; then
|
||||
stat -c %a "$file"
|
||||
else
|
||||
stat -f %Lp "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
tmp="$(new_tmp)"
|
||||
bin_dir="$tmp/bin"
|
||||
install_fake_tools "$bin_dir"
|
||||
|
||||
home_generated="$tmp/generated"
|
||||
stdout_generated="$tmp/generated.out"
|
||||
mkdir -p "$home_generated"
|
||||
run_runtime "$home_generated" "$bin_dir" "$stdout_generated"
|
||||
|
||||
generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)"
|
||||
[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \
|
||||
|| fail "runtime should generate a persisted anonymous download secret"
|
||||
[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \
|
||||
|| fail "runtime env file must be readable only by the owner"
|
||||
grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \
|
||||
|| fail "runtime should explain that it generated the secret"
|
||||
if grep -Fq "$generated_secret" "$stdout_generated"; then
|
||||
fail "runtime must not print the generated secret value"
|
||||
fi
|
||||
|
||||
home_preserved="$tmp/preserved"
|
||||
stdout_preserved="$tmp/preserved.out"
|
||||
mkdir -p "$home_preserved"
|
||||
cat >"$home_preserved/.env.release" <<'EOF'
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes
|
||||
EOF
|
||||
run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved"
|
||||
|
||||
preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)"
|
||||
[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \
|
||||
|| fail "runtime must preserve an existing valid anonymous download secret"
|
||||
[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \
|
||||
|| fail "runtime env file must remain owner-readable only when an existing secret is preserved"
|
||||
if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then
|
||||
fail "runtime must not regenerate an existing valid secret"
|
||||
fi
|
||||
|
||||
home_no_scanner="$tmp/no-scanner"
|
||||
stdout_no_scanner="$tmp/no-scanner.out"
|
||||
mkdir -p "$home_no_scanner"
|
||||
run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner
|
||||
|
||||
grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \
|
||||
|| fail "runtime should persist scanner disabled state for --no-scanner"
|
||||
grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \
|
||||
|| fail "runtime --no-scanner should start server/web without waiting on scanner dependencies"
|
||||
|
||||
echo "runtime-secret-test passed"
|
||||
232
scripts/tests/scanner-llm-base-url-test.sh
Executable file
232
scripts/tests/scanner-llm-base-url-test.sh
Executable 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"
|
||||
96
scripts/tests/validate-release-config-test.sh
Executable file
96
scripts/tests/validate-release-config-test.sh
Executable file
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh"
|
||||
|
||||
TMP_DIRS=()
|
||||
cleanup() {
|
||||
local d
|
||||
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
new_tmp() {
|
||||
local d
|
||||
d="$(mktemp -d)"
|
||||
TMP_DIRS+=("$d")
|
||||
echo "$d"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local file="$1"
|
||||
local secret="${2:-}"
|
||||
local include_secret="${3:-yes}"
|
||||
cat >"$file" <<EOF
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
|
||||
POSTGRES_DB=skillhub
|
||||
POSTGRES_USER=skillhub
|
||||
POSTGRES_PASSWORD=strong-postgres-password
|
||||
SESSION_COOKIE_SECURE=true
|
||||
BOOTSTRAP_ADMIN_ENABLED=false
|
||||
SKILLHUB_STORAGE_PROVIDER=s3
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
|
||||
SKILLHUB_STORAGE_S3_BUCKET=skillhub
|
||||
SKILLHUB_STORAGE_S3_ACCESS_KEY=release-access-key
|
||||
SKILLHUB_STORAGE_S3_SECRET_KEY=release-secret-key
|
||||
SKILLHUB_STORAGE_S3_REGION=us-east-1
|
||||
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
|
||||
EOF
|
||||
if [[ "$include_secret" == "yes" ]]; then
|
||||
printf 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=%s\n' "$secret" >>"$file"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_fail() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local output
|
||||
if output="$("$SCRIPT" "$file" 2>&1)"; then
|
||||
fail "expected validation to fail for $file"
|
||||
fi
|
||||
if [[ "$output" != *"$expected"* ]]; then
|
||||
fail "expected output to contain '$expected', got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
tmp="$(new_tmp)"
|
||||
|
||||
valid_env="$tmp/valid.env"
|
||||
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
|
||||
"$SCRIPT" "$valid_env" >/dev/null
|
||||
|
||||
missing_env="$tmp/missing.env"
|
||||
write_env "$missing_env" "" no
|
||||
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
|
||||
|
||||
placeholder_env="$tmp/placeholder.env"
|
||||
write_env "$placeholder_env" "change-me-in-production"
|
||||
expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value"
|
||||
|
||||
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"
|
||||
|
||||
draft_env="$tmp/draft.env"
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*)
|
||||
printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$line"
|
||||
;;
|
||||
esac
|
||||
done <"$REPO_ROOT/.env.release.draft" >"$draft_env"
|
||||
expect_fail "$draft_env" "POSTGRES_PASSWORD"
|
||||
|
||||
echo "validate-release-config-test passed"
|
||||
76
scripts/tests/workflow-security-test.sh
Executable file
76
scripts/tests/workflow-security-test.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml"
|
||||
PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_pr_workflow_hardened() {
|
||||
local workflow="$1"
|
||||
grep -Eq '^permissions:[[:space:]]*$' "$workflow" \
|
||||
|| fail "$workflow must declare top-level permissions"
|
||||
grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \
|
||||
|| fail "$workflow GITHUB_TOKEN permissions must include contents: read"
|
||||
grep -Fq 'persist-credentials: false' "$workflow" \
|
||||
|| fail "$workflow checkout steps must not persist credentials"
|
||||
}
|
||||
|
||||
[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required"
|
||||
|
||||
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-tests.yml"
|
||||
assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW"
|
||||
assert_pr_workflow_hardened "$SECURITY_WORKFLOW"
|
||||
|
||||
grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must run dependency review"
|
||||
grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must initialize CodeQL"
|
||||
grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must build Java with the server Maven wrapper"
|
||||
grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must grant SARIF upload permission"
|
||||
|
||||
python_source="$(find "$REPO_ROOT" \
|
||||
\( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \
|
||||
-type f -name '*.py' -print -quit)"
|
||||
if [[ -n "$python_source" ]]; then
|
||||
grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must run Python CodeQL when Python source exists"
|
||||
else
|
||||
! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|
||||
|| fail "security workflow must not run Python CodeQL without Python source"
|
||||
fi
|
||||
|
||||
grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \
|
||||
|| fail "pr-scripts must run when security workflow changes"
|
||||
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-tests.yml' "$PR_SCRIPTS_WORKFLOW" \
|
||||
|| fail "pr-scripts must run when PR Tests 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" \
|
||||
|| fail "pr-scripts must run when release env example changes"
|
||||
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 '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/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" \
|
||||
|| fail "pr-scripts must run dev-web-host-test"
|
||||
grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|
||||
|| fail "pr-scripts must run workflow-security-test"
|
||||
|
||||
echo "workflow-security-test passed"
|
||||
|
|
@ -52,6 +52,23 @@ reject_values() {
|
|||
done
|
||||
}
|
||||
|
||||
reject_patterns() {
|
||||
var_name="$1"
|
||||
shift
|
||||
eval "var_value=\${$var_name:-}"
|
||||
if [ -z "$var_value" ]; then
|
||||
return 0
|
||||
fi
|
||||
for pattern in "$@"; do
|
||||
case "$var_value" in
|
||||
$pattern)
|
||||
error "$var_name still uses placeholder/default pattern: $var_value"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
validate_url() {
|
||||
var_name="$1"
|
||||
eval "var_value=\${$var_name:-}"
|
||||
|
|
@ -97,17 +114,40 @@ validate_port() {
|
|||
esac
|
||||
}
|
||||
|
||||
validate_min_length() {
|
||||
var_name="$1"
|
||||
min_length="$2"
|
||||
eval "var_value=\${$var_name:-}"
|
||||
if [ -z "$var_value" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "${#var_value}" -lt "$min_length" ]; then
|
||||
error "$var_name must be at least $min_length characters"
|
||||
fi
|
||||
}
|
||||
|
||||
require_non_empty SKILLHUB_PUBLIC_BASE_URL
|
||||
validate_url SKILLHUB_PUBLIC_BASE_URL
|
||||
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
|
||||
|
||||
require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET
|
||||
reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes"
|
||||
reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*"
|
||||
validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32
|
||||
|
||||
reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev"
|
||||
reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*"
|
||||
reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026"
|
||||
reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*"
|
||||
if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then
|
||||
require_non_empty BOOTSTRAP_ADMIN_PASSWORD
|
||||
fi
|
||||
reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me"
|
||||
reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me"
|
||||
reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*"
|
||||
reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*"
|
||||
reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*"
|
||||
reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
|
||||
|
||||
validate_boolean SESSION_COOKIE_SECURE
|
||||
validate_boolean BOOTSTRAP_ADMIN_ENABLED
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub;
|
||||
|
||||
import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties;
|
||||
import com.iflytek.skillhub.config.ProfileFieldPolicyProperties;
|
||||
import com.iflytek.skillhub.config.ProfileModerationProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
|
@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||
* Main Spring Boot entry point for the SkillHub backend application.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class})
|
||||
@EnableConfigurationProperties({
|
||||
BuiltinSkillProperties.class,
|
||||
ProfileModerationProperties.class,
|
||||
ProfileFieldPolicyProperties.class
|
||||
})
|
||||
public class SkillhubApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SkillhubApplication.class, args);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem;
|
||||
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Best-effort startup synchronizer for remotely hosted built-in skill packages.
|
||||
*/
|
||||
@Component
|
||||
public class BuiltinSkillInitializer {
|
||||
|
||||
static final String GLOBAL_NAMESPACE = "global";
|
||||
static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class);
|
||||
private static final Set<String> SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN");
|
||||
private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true;
|
||||
|
||||
private final BuiltinSkillProperties properties;
|
||||
private final BuiltinSkillManifestLoader manifestLoader;
|
||||
private final BuiltinSkillRemotePackageDownloader downloader;
|
||||
private final BuiltinSkillPackageExtractor extractor;
|
||||
private final SkillMetadataParser metadataParser;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillFileRepository skillFileRepository;
|
||||
private final SkillPublishService skillPublishService;
|
||||
|
||||
public BuiltinSkillInitializer(
|
||||
BuiltinSkillProperties properties,
|
||||
BuiltinSkillManifestLoader manifestLoader,
|
||||
BuiltinSkillRemotePackageDownloader downloader,
|
||||
BuiltinSkillPackageExtractor extractor,
|
||||
SkillMetadataParser metadataParser,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillFileRepository skillFileRepository,
|
||||
SkillPublishService skillPublishService) {
|
||||
this.properties = properties;
|
||||
this.manifestLoader = manifestLoader;
|
||||
this.downloader = downloader;
|
||||
this.extractor = extractor;
|
||||
this.metadataParser = metadataParser;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillFileRepository = skillFileRepository;
|
||||
this.skillPublishService = skillPublishService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Async("skillhubEventExecutor")
|
||||
public void synchronizeAfterApplicationReady() {
|
||||
synchronize();
|
||||
}
|
||||
|
||||
void synchronize() {
|
||||
if (!properties.isEnabled()) {
|
||||
log.info("Built-in skill startup synchronization is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Namespace> namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE);
|
||||
if (namespace.isEmpty()) {
|
||||
log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization",
|
||||
GLOBAL_NAMESPACE);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ManifestItem> items = manifestLoader.load();
|
||||
if (items.isEmpty()) {
|
||||
log.info("No built-in skill manifest items to synchronize");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ensureSystemPublisher(namespace.get())) {
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}",
|
||||
exception.getMessage(), exception);
|
||||
return;
|
||||
}
|
||||
|
||||
int published = 0;
|
||||
int idempotentSkipped = 0;
|
||||
int conflictSkipped = 0;
|
||||
int failed = 0;
|
||||
for (ManifestItem item : items) {
|
||||
try {
|
||||
SyncOutcome outcome = syncItem(namespace.get(), item);
|
||||
switch (outcome) {
|
||||
case PUBLISHED -> published++;
|
||||
case IDEMPOTENT_SKIPPED -> idempotentSkipped++;
|
||||
case CONFLICT_SKIPPED -> conflictSkipped++;
|
||||
case FAILED -> failed++;
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
failed++;
|
||||
log.error(
|
||||
"Failed to synchronize built-in skill slug={} version={}: {}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
exception.getMessage(),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
log.info(
|
||||
"Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}",
|
||||
items.size(),
|
||||
published,
|
||||
idempotentSkipped,
|
||||
conflictSkipped,
|
||||
failed
|
||||
);
|
||||
}
|
||||
|
||||
private boolean ensureSystemPublisher(Namespace namespace) {
|
||||
Optional<UserAccount> existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID);
|
||||
UserAccount publisher;
|
||||
if (existingPublisher.isPresent()) {
|
||||
publisher = existingPublisher.get();
|
||||
} else {
|
||||
publisher = UserAccount.systemAccount(
|
||||
SYSTEM_PUBLISHER_ID,
|
||||
"Built-in Skill Publisher",
|
||||
null,
|
||||
null
|
||||
);
|
||||
userAccountRepository.save(publisher);
|
||||
}
|
||||
if (!publisher.isSystemAccount()) {
|
||||
log.error("Built-in skill publisher account id '{}' already exists but is not a system account; "
|
||||
+ "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) {
|
||||
namespaceMemberRepository.save(new NamespaceMember(
|
||||
namespace.getId(),
|
||||
SYSTEM_PUBLISHER_ID,
|
||||
NamespaceRole.OWNER
|
||||
));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception {
|
||||
Optional<SyncOutcome> skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item);
|
||||
if (skipBeforeDownload.isPresent()) {
|
||||
return skipBeforeDownload.get();
|
||||
}
|
||||
|
||||
Optional<URI> packageUri = parsePackageUri(item);
|
||||
if (packageUri.isEmpty()) {
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
Optional<byte[]> packageBytes = downloader.download(packageUri.get());
|
||||
if (packageBytes.isEmpty()) {
|
||||
log.warn("Skipping built-in skill slug={} version={} because package download failed",
|
||||
item.slug(), item.version());
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get());
|
||||
List<PackageEntry> entries = extractionResult.entries();
|
||||
SkillMetadata metadata = parseSkillMetadata(entries);
|
||||
String packageSlug = SlugValidator.slugify(metadata.name());
|
||||
if (!item.slug().equals(packageSlug)) {
|
||||
log.warn(
|
||||
"Skipping built-in skill manifest slug={} version={} because package slug is {}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
packageSlug
|
||||
);
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
if (!item.version().equals(metadata.version())) {
|
||||
log.warn(
|
||||
"Skipping built-in skill slug={} because manifest version {} does not match package version {}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
metadata.version()
|
||||
);
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
Optional<SyncOutcome> skipExisting = shouldSkipExisting(namespace.getId(), item, entries);
|
||||
if (skipExisting.isPresent()) {
|
||||
return skipExisting.get();
|
||||
}
|
||||
|
||||
try {
|
||||
skillPublishService.publishFromEntries(
|
||||
GLOBAL_NAMESPACE,
|
||||
entries,
|
||||
SYSTEM_PUBLISHER_ID,
|
||||
SkillVisibility.PUBLIC,
|
||||
SYSTEM_PUBLISHER_ROLES,
|
||||
CONFIRM_BUILTIN_PUBLISH_WARNINGS
|
||||
);
|
||||
log.info("Published built-in skill slug={} version={} to @{}",
|
||||
item.slug(), item.version(), GLOBAL_NAMESPACE);
|
||||
return SyncOutcome.PUBLISHED;
|
||||
} catch (RuntimeException exception) {
|
||||
if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) {
|
||||
log.info("Built-in skill slug={} version={} was published concurrently, skipping",
|
||||
item.slug(), item.version());
|
||||
return SyncOutcome.IDEMPOTENT_SKIPPED;
|
||||
}
|
||||
log.error("Failed to publish built-in skill slug={} version={}: {}",
|
||||
item.slug(), item.version(), exception.getMessage(), exception);
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<URI> parsePackageUri(ManifestItem item) {
|
||||
try {
|
||||
return Optional.of(URI.create(item.url()));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}",
|
||||
item.slug(), item.version(), exception.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private SkillMetadata parseSkillMetadata(List<PackageEntry> entries) {
|
||||
PackageEntry skillMd = entries.stream()
|
||||
.filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH));
|
||||
return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private Optional<SyncOutcome> shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) {
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
|
||||
if (hasOtherOwnerConflict(existingSkills)) {
|
||||
log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user",
|
||||
item.slug());
|
||||
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
|
||||
}
|
||||
|
||||
Optional<Skill> builtinSkill = existingSkills.stream()
|
||||
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
|
||||
.findFirst();
|
||||
if (builtinSkill.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<SkillVersion> existingVersion = skillVersionRepository
|
||||
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
|
||||
if (existingVersion.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
SkillVersion version = existingVersion.get();
|
||||
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
log.info("Skipping built-in skill slug={} version={} before download because it is already published",
|
||||
item.slug(), item.version());
|
||||
} else {
|
||||
log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}",
|
||||
item.slug(), item.version(), version.getStatus());
|
||||
}
|
||||
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
|
||||
}
|
||||
|
||||
private Optional<SyncOutcome> shouldSkipExisting(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
|
||||
if (hasOtherOwnerConflict(existingSkills)) {
|
||||
log.warn("Skipping built-in skill slug={} because the slug already belongs to another user",
|
||||
item.slug());
|
||||
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
|
||||
}
|
||||
|
||||
Optional<Skill> builtinSkill = existingSkills.stream()
|
||||
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
|
||||
.findFirst();
|
||||
if (builtinSkill.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<SkillVersion> existingVersion = skillVersionRepository
|
||||
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
|
||||
if (existingVersion.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
SkillVersion version = existingVersion.get();
|
||||
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
log.info("Skipping built-in skill slug={} version={} because existing version status is {}",
|
||||
item.slug(), item.version(), version.getStatus());
|
||||
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
|
||||
}
|
||||
|
||||
String packageFingerprint = computeFingerprint(entries);
|
||||
String existingFingerprint = computeFingerprint(version);
|
||||
if (packageFingerprint.equals(existingFingerprint)) {
|
||||
log.info("Skipping built-in skill slug={} version={} because it is already published",
|
||||
item.slug(), item.version());
|
||||
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
|
||||
} else {
|
||||
log.warn(
|
||||
"Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
existingFingerprint,
|
||||
packageFingerprint
|
||||
);
|
||||
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
|
||||
for (Skill skill : existingSkills) {
|
||||
if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) {
|
||||
continue;
|
||||
}
|
||||
Optional<SkillVersion> version = skillVersionRepository
|
||||
.findBySkillIdAndVersion(skill.getId(), item.version());
|
||||
if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
String packageFingerprint = computeFingerprint(entries);
|
||||
String existingFingerprint = computeFingerprint(version.get());
|
||||
if (packageFingerprint.equals(existingFingerprint)) {
|
||||
return true;
|
||||
}
|
||||
log.warn(
|
||||
"Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}",
|
||||
item.slug(),
|
||||
item.version(),
|
||||
existingFingerprint,
|
||||
packageFingerprint
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasOtherOwnerConflict(List<Skill> existingSkills) {
|
||||
return existingSkills.stream()
|
||||
.anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()));
|
||||
}
|
||||
|
||||
private String computeFingerprint(SkillVersion version) {
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
.toList();
|
||||
return computeFingerprintFromFileDigests(files.stream()
|
||||
.map(file -> new FileDigest(file.getFilePath(), file.getSha256()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private String computeFingerprint(List<PackageEntry> entries) {
|
||||
return computeFingerprintFromFileDigests(entries.stream()
|
||||
.map(entry -> new FileDigest(entry.path(), sha256(entry.content())))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private String computeFingerprintFromFileDigests(List<FileDigest> files) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) {
|
||||
String line = file.path() + ":" + file.sha256() + "\n";
|
||||
digest.update(line.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return "sha256:" + HexFormat.of().formatHex(digest.digest());
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(content));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to compute built-in skill file digest", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record FileDigest(String path, String sha256) {
|
||||
}
|
||||
|
||||
private enum SyncOutcome {
|
||||
PUBLISHED,
|
||||
IDEMPOTENT_SKIPPED,
|
||||
CONFLICT_SKIPPED,
|
||||
FAILED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class BuiltinSkillManifestLoader {
|
||||
|
||||
static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json";
|
||||
static final int MAX_ITEMS = 100;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.resourceLoader = resourceLoader;
|
||||
}
|
||||
|
||||
public List<ManifestItem> load() {
|
||||
Resource resource = resourceLoader.getResource(MANIFEST_LOCATION);
|
||||
if (!resource.exists()) {
|
||||
log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
JsonNode root;
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
root = objectMapper.readTree(inputStream);
|
||||
} catch (IOException | RuntimeException ex) {
|
||||
log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (root == null || root.isNull()) {
|
||||
log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
JsonNode skillsNode = root.path("skills");
|
||||
if (!skillsNode.isArray()) {
|
||||
log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<ManifestItem> items = new ArrayList<>();
|
||||
Set<String> seenSlugVersions = new HashSet<>();
|
||||
int totalEntries = skillsNode.size();
|
||||
if (totalEntries > MAX_ITEMS) {
|
||||
log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed",
|
||||
totalEntries, MAX_ITEMS);
|
||||
}
|
||||
int limit = Math.min(totalEntries, MAX_ITEMS);
|
||||
for (int index = 0; index < limit; index++) {
|
||||
JsonNode itemNode = skillsNode.get(index);
|
||||
String slug = text(itemNode, "slug");
|
||||
String version = text(itemNode, "version");
|
||||
String url = text(itemNode, "url");
|
||||
if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) {
|
||||
log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
SlugValidator.validate(slug);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}",
|
||||
index, slug, ex.getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = slug + "\n" + version;
|
||||
if (!seenSlugVersions.add(key)) {
|
||||
log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version);
|
||||
continue;
|
||||
}
|
||||
|
||||
items.add(new ManifestItem(slug, version, url));
|
||||
}
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String fieldName) {
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value == null || !value.isTextual()) {
|
||||
return "";
|
||||
}
|
||||
return value.asText().trim();
|
||||
}
|
||||
|
||||
public record ManifestItem(String slug, String version, String url) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@Component
|
||||
public class BuiltinSkillPackageExtractor {
|
||||
|
||||
private final SkillPackageArchiveExtractor archiveExtractor;
|
||||
|
||||
public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) {
|
||||
this.archiveExtractor = archiveExtractor;
|
||||
}
|
||||
|
||||
public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException {
|
||||
SkillPackageArchiveExtractor.ExtractionResult result =
|
||||
archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes));
|
||||
if (!result.warnings().isEmpty()) {
|
||||
throw new IllegalArgumentException("Built-in skill package has warnings: "
|
||||
+ String.join("; ", result.warnings()));
|
||||
}
|
||||
boolean hasSkillMd = result.entries().stream()
|
||||
.anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()));
|
||||
if (!hasSkillMd) {
|
||||
throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "file";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return "builtin-skill.zip";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return "application/zip";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return bytes.length == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSize() {
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transferTo(java.io.File dest) throws IOException {
|
||||
throw new UnsupportedOperationException("Built-in skill zip adapter is read-only");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "skillhub.builtin-skills")
|
||||
public class BuiltinSkillProperties {
|
||||
|
||||
private boolean enabled = true;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue