From a81c787a0b39e61a33ae2b7565ba5d8129ad5ad7 Mon Sep 17 00:00:00 2001 From: arch3rPro Date: Wed, 1 Apr 2026 18:41:10 +0800 Subject: [PATCH] feat: update skills --- skills/SKILL.md | 69 ++++++- skills/scripts/generate-app.sh | 366 +++++++++++++++++++++++++++++---- 2 files changed, 388 insertions(+), 47 deletions(-) diff --git a/skills/SKILL.md b/skills/SKILL.md index d06696b..ea1e815 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -59,7 +59,23 @@ Based on user input type, extract Docker deployment details: 2. Extract the image name and tag 3. Identify exposed ports and data directories -### Step 2: Generate App Key and Metadata +### Step 2: Resolve Latest Version (Create Version + latest) + +Always create **two** version directories when possible: + +- `latest/` uses image tag `latest` +- `/` uses the **latest concrete tag** from the registry + +**How to resolve the latest concrete tag:** + +1. If the image is on **GitHub Container Registry** (`ghcr.io`), query tags from GitHub Packages (GHCR). +2. Otherwise, query **Docker Hub** tags. +3. Prefer the newest semver-like tag (e.g., `v1.2.3` or `1.2.3`). If none exist, pick the most recently updated non-`latest` tag. +4. If you cannot resolve a concrete tag, fall back to the image tag from input and warn the user. + +**Rule:** The `latest/` directory must **always** use `image: ...:latest`. The `/` directory must **always** use `image: ...:`. + +### Step 3: Generate App Key and Metadata **App Key Rules:** - Lowercase only @@ -100,7 +116,7 @@ additionalProperties: # Add: arm/v7, arm/v6, s390x if supported ``` -### Step 3: Define Parameters (version/data.yml) +### Step 4: Define Parameters (version/data.yml) Parameters become UI form fields in 1Panel. Define user-configurable values: @@ -170,7 +186,25 @@ additionalProperties: - `paramExtUrl`: Valid URL format - Empty string: No validation -### Step 4: Create docker-compose.yml +### Port envKey Naming (Use 1Panel Standard Names) + +Prefer these **standard** `envKey` names (observed across apps in `apps/`): + +- `PANEL_APP_PORT_HTTP` (primary Web/UI port) +- `PANEL_APP_PORT_HTTPS` +- `PANEL_APP_PORT_API` +- `PANEL_APP_PORT_ADMIN` +- `PANEL_APP_PORT_PROXY` +- `PANEL_APP_PORT_PROXY_HTTP` +- `PANEL_APP_PORT_PROXY_HTTPS` +- `PANEL_APP_PORT_DB` +- `PANEL_APP_PORT_SSH` +- `PANEL_APP_PORT_S3` +- `PANEL_APP_PORT_SYNC` + +**Guideline:** Use the most semantically correct name first; only invent new suffixes if none of the standard names fit. + +### Step 5: Create docker-compose.yml Convert the original Docker deployment to use variable substitution: @@ -199,12 +233,20 @@ networks: 1. Always use `${CONTAINER_NAME}` for container_name 2. Always use `restart: always` 3. Always connect to `1panel-network` (external network) -4. Port mapping uses `PANEL_APP_PORT_*` variables from data.yml +4. Port mapping uses `PANEL_APP_PORT_*` variables from version/data.yml 5. Volume paths use relative `./data/` for persistence 6. Add `labels: createdBy: "Apps"` 7. Keep original environment variables but use defaults -### Step 5: Create README Files +**Port Mapping Format (Important):** + +Use quoted mappings like: +``` +- "${PANEL_APP_PORT_HTTP}:8080" +``` +and ensure the same `envKey` exists in `version/data.yml`. + +### Step 6: Create README Files **README.md (Chinese):** ```markdown @@ -269,17 +311,19 @@ Brief description of the application. - GitHub: https://github.com/org/repo ``` -### Step 6: Download App Icon +### Step 7: Download App Icon -Search and download the app logo from these sources (in order): +**Priority order:** +1. **Search the GitHub repository** for common icon files (e.g., `logo.png`, `icon.png`, `assets/logo.png`, `.github/icon.png`). Use the repo's raw download URL if found. +2. If not found, search and download the app logo from these sources (in order): 1. **Dashboard Icons**: https://dashboardicons.com/icons?q={app-name} 2. **Simple Icons**: https://simpleicons.org/?q={app-name} 3. **Selfh.st Icons**: https://selfh.st/icons/ -Save as `logo.png` in the app root directory. +**Do not** create a placeholder or incorrect icon. If not found, warn and leave `logo.png` for manual replacement. -### Step 7: Create Data Directory Structure +### Step 8: Create Data Directory Structure Create the `data/` directory inside the version folder: ```bash @@ -294,13 +338,16 @@ Before delivering the app package, verify: - [ ] App key is lowercase with hyphens only - [ ] All required fields in top-level data.yml are present -- [ ] Version data.yml has proper parameter definitions +- [ ] `latest/` and `/` directories both exist (when a concrete tag is resolvable) +- [ ] `latest/` uses image tag `latest` +- [ ] `/` uses the concrete latest tag from registry +- [ ] Version data.yml has proper parameter definitions (formFields) - [ ] docker-compose.yml uses variable substitution correctly - [ ] `1panel-network` is defined as external network - [ ] Volume paths use relative `./data/` format - [ ] README files are created with proper documentation - [ ] Logo file exists (logo.png) -- [ ] Version directory follows semver or uses "latest" +- [ ] Version directory follows semver; avoid only `latest` ## Common Patterns diff --git a/skills/scripts/generate-app.sh b/skills/scripts/generate-app.sh index 5bd3e89..db43b13 100644 --- a/skills/scripts/generate-app.sh +++ b/skills/scripts/generate-app.sh @@ -67,6 +67,171 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +# 解析镜像引用(支持 registry/namespace/repo:tag) +parse_image_ref() { + local image_ref="$1" + local image_no_tag tag + + # 去掉 digest + image_ref="${image_ref%%@*}" + + if [[ "$image_ref" =~ ^(.+):([^/:]+)$ ]]; then + image_no_tag="${BASH_REMATCH[1]}" + tag="${BASH_REMATCH[2]}" + else + image_no_tag="$image_ref" + tag="latest" + fi + + local first_segment="${image_no_tag%%/*}" + if [[ "$image_no_tag" == */* ]] && ([[ "$first_segment" == *.* ]] || [[ "$first_segment" == *:* ]] || [[ "$first_segment" == "localhost" ]]); then + REGISTRY="$first_segment" + IMAGE_PATH="${image_no_tag#*/}" + else + REGISTRY="docker.io" + IMAGE_PATH="$image_no_tag" + fi + + if [[ "$IMAGE_PATH" == */* ]]; then + NAMESPACE="${IMAGE_PATH%%/*}" + REPO_PATH="${IMAGE_PATH#*/}" + else + NAMESPACE="library" + REPO_PATH="$IMAGE_PATH" + fi + + IMAGE_BASE="$image_no_tag" + TAG="$tag" +} + +# 获取 Docker Hub 最新版本号 +get_latest_tag_docker_hub() { + local namespace="$1" + local repo_path="$2" + local url="https://hub.docker.com/v2/repositories/${namespace}/${repo_path}/tags?page_size=100&ordering=last_updated" + local tags + tags=$(curl -s "$url" | jq -r '.results[].name' 2>/dev/null || true) + + local semver + semver=$(echo "$tags" | grep -E '^[vV]?[0-9]+\\.[0-9]+(\\.[0-9]+)?$' | head -n1 || true) + if [[ -n "$semver" ]]; then + echo "$semver" + return 0 + fi + + local nonlatest + nonlatest=$(echo "$tags" | grep -v '^latest$' | head -n1 || true) + echo "${nonlatest:-latest}" +} + +# 获取 GHCR 最新版本号 +get_latest_tag_ghcr() { + local namespace="$1" + local repo_path="$2" + local token tags + + token=$(curl -s "https://ghcr.io/token?service=ghcr.io&scope=repository:${namespace}/${repo_path}:pull" | jq -r '.token' 2>/dev/null || true) + if [[ -z "$token" || "$token" == "null" ]]; then + echo "latest" + return 0 + fi + + tags=$(curl -s -H "Authorization: Bearer ${token}" "https://ghcr.io/v2/${namespace}/${repo_path}/tags/list" | jq -r '.tags[]' 2>/dev/null || true) + + local semver + semver=$(echo "$tags" | grep -E '^[vV]?[0-9]+\\.[0-9]+(\\.[0-9]+)?$' | sort -V | tail -n1 || true) + if [[ -n "$semver" ]]; then + echo "$semver" + return 0 + fi + + local nonlatest + nonlatest=$(echo "$tags" | grep -v '^latest$' | sort -V | tail -n1 || true) + echo "${nonlatest:-latest}" +} + +# 统一获取最新版本号 +get_latest_tag() { + if [[ "$REGISTRY" == "ghcr.io" ]]; then + get_latest_tag_ghcr "$NAMESPACE" "$REPO_PATH" + return 0 + fi + + # 默认走 Docker Hub + get_latest_tag_docker_hub "$NAMESPACE" "$REPO_PATH" +} + +# 解析端口映射条目为 host:container +parse_port_entry() { + local entry="$1" + entry="${entry%\"}" + entry="${entry#\"}" + if [[ "$entry" == */* ]]; then + entry="${entry%/*}" + fi + IFS=':' read -r -a parts <<< "$entry" + local host_port container_port + if [[ ${#parts[@]} -eq 1 ]]; then + host_port="${parts[0]}" + container_port="${parts[0]}" + elif [[ ${#parts[@]} -eq 2 ]]; then + host_port="${parts[0]}" + container_port="${parts[1]}" + else + host_port="${parts[-2]}" + container_port="${parts[-1]}" + fi + echo "${host_port}:${container_port}" +} + +# 根据端口号选择 1Panel 常用 envKey +map_port_envkey() { + local port="$1" + local index="$2" + case "$port" in + 80|8080|3000|5173|8000|5000) + echo "PANEL_APP_PORT_HTTP" + ;; + 443|8443) + echo "PANEL_APP_PORT_HTTPS" + ;; + 22) + echo "PANEL_APP_PORT_SSH" + ;; + 3306|5432|27017|6379) + echo "PANEL_APP_PORT_DB" + ;; + 9000|9001|8081|7001) + echo "PANEL_APP_PORT_API" + ;; + *) + if [[ "$index" -eq 0 ]]; then + echo "PANEL_APP_PORT_HTTP" + else + echo "PANEL_APP_PORT_API" + fi + ;; + esac +} + +# envKey 对应标签 +labels_for_envkey() { + local envkey="$1" + case "$envkey" in + PANEL_APP_PORT_HTTP) echo "Web Port|Web端口" ;; + PANEL_APP_PORT_HTTPS) echo "HTTPS Port|HTTPS端口" ;; + PANEL_APP_PORT_API) echo "API Port|API端口" ;; + PANEL_APP_PORT_ADMIN) echo "Admin Port|管理端口" ;; + PANEL_APP_PORT_PROXY) echo "Proxy Port|代理端口" ;; + PANEL_APP_PORT_DB) echo "DB Port|数据库端口" ;; + PANEL_APP_PORT_SSH) echo "SSH Port|SSH端口" ;; + PANEL_APP_PORT_S3) echo "S3 Port|S3端口" ;; + PANEL_APP_PORT_PROXY_HTTP) echo "Proxy HTTP Port|代理HTTP端口" ;; + PANEL_APP_PORT_PROXY_HTTPS) echo "Proxy HTTPS Port|代理HTTPS端口" ;; + PANEL_APP_PORT_SYNC) echo "Sync Port|同步端口" ;; + *) echo "Port|端口" ;; + esac +} # 检测输入类型 detect_input_type() { local input="$1" @@ -146,22 +311,22 @@ extract_from_compose() { # 提取镜像 local image image=$(echo "$compose_content" | yq ".services.${SERVICE_NAME}.image" 2>/dev/null || echo "") - - # 解析镜像名和标签 - if [[ "$image" =~ ^([^:]+):(.+)$ ]]; then - IMAGE="${BASH_REMATCH[1]}" - TAG="${BASH_REMATCH[2]}" - else - IMAGE="$image" - TAG="latest" - fi + parse_image_ref "$image" # 提取端口 - PORT=$(echo "$compose_content" | yq ".services.${SERVICE_NAME}.ports[0]" 2>/dev/null | grep -oE '[0-9]+$' || echo "8080") + PORT_ENTRIES=() + while IFS= read -r line; do + [[ -z "$line" || "$line" == "null" ]] && continue + PORT_ENTRIES+=("$line") + done < <(echo "$compose_content" | yq ".services.${SERVICE_NAME}.ports[]" 2>/dev/null || true) + + if [[ ${#PORT_ENTRIES[@]} -eq 0 ]]; then + PORT_ENTRIES=("8080") + fi log_info "服务名: $SERVICE_NAME" - log_info "镜像: $IMAGE:$TAG" - log_info "端口: $PORT" + log_info "镜像: $IMAGE_BASE:$TAG" + log_info "端口数量: ${#PORT_ENTRIES[@]}" } # 从 docker run 命令提取信息 @@ -171,21 +336,30 @@ extract_from_docker_run() { log_info "正在解析 docker run 命令" # 提取镜像 - IMAGE=$(echo "$cmd" | grep -oE '[^ ]+:[^ ]+$' | cut -d':' -f1 || echo "") - TAG=$(echo "$cmd" | grep -oE '[^ ]+:[^ ]+$' | cut -d':' -f2 || echo "latest") + local image_ref + image_ref=$(echo "$cmd" | awk '{print $NF}') + parse_image_ref "$image_ref" # 提取容器名 SERVICE_NAME=$(echo "$cmd" | grep -oE '\-\-name[= ]+[^ ]+' | awk '{print $NF}' || echo "app") # 提取端口 - PORT=$(echo "$cmd" | grep -oE '\-p[= ]+[0-9]+:[0-9]+' | grep -oE '[0-9]+$' || echo "8080") + PORT_ENTRIES=() + while IFS= read -r line; do + [[ -z "$line" ]] && continue + PORT_ENTRIES+=("$line") + done < <(echo "$cmd" | grep -oE '\-p[= ]+[^ ]+' | awk '{print $NF}' || true) + + if [[ ${#PORT_ENTRIES[@]} -eq 0 ]]; then + PORT_ENTRIES=("8080") + fi APP_NAME="$SERVICE_NAME" APP_KEY=$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]') log_info "应用名称: $APP_NAME" - log_info "镜像: $IMAGE:$TAG" - log_info "端口: $PORT" + log_info "镜像: $IMAGE_BASE:$TAG" + log_info "端口数量: ${#PORT_ENTRIES[@]}" } # 生成 data.yml @@ -231,9 +405,45 @@ additionalProperties: EOF } +# 生成 version/data.yml(参数定义) +generate_version_data_yml() { + local output_file="$1" + + log_info "生成版本 data.yml: $output_file" + + cat > "$output_file" << EOF +additionalProperties: + formFields: +EOF + + local i + for ((i=0; i<${#CONTAINER_PORTS[@]}; i++)); do + local envkey="${PORT_ENV_KEYS[$i]}" + local default_port="${HOST_PORTS[$i]}" + local labels + labels=$(labels_for_envkey "$envkey") + local label_en="${labels%%|*}" + local label_zh="${labels##*|}" + cat >> "$output_file" << EOF + - default: ${default_port} + edit: true + envKey: ${envkey} + labelEn: ${label_en} + labelZh: ${label_zh} + required: true + rule: paramPort + type: number + label: + en: ${label_en} + zh: ${label_zh} +EOF + done +} + # 生成 docker-compose.yml generate_docker_compose() { local output_file="$1" + local image_tag="$2" log_info "生成 docker-compose.yml: $output_file" @@ -245,14 +455,23 @@ services: networks: - 1panel-network ports: - - "\${PANEL_APP_PORT_HTTP}:${PORT:-8080}" +EOF + + local i + for ((i=0; i<${#CONTAINER_PORTS[@]}; i++)); do + cat >> "$output_file" << EOF + - "\${${PORT_ENV_KEYS[$i]}}:${CONTAINER_PORTS[$i]}" +EOF + done + + cat >> "$output_file" << EOF volumes: - ./data/data:/app/data environment: - PUID=0 - PGID=0 - UMASK=022 - image: ${IMAGE:-app}:${TAG:-latest} + image: ${IMAGE_BASE:-app}:${image_tag} labels: createdBy: "Apps" networks: @@ -312,11 +531,41 @@ EOF download_icon() { local app_name="$1" local output_file="$2" + local github_url="${3:-}" local icon_name icon_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') - log_info "尝试下载图标: $app_name" + log_info "尝试获取图标: $app_name" + + # 优先从 GitHub 仓库内查找常见图标文件 + if [[ -n "$github_url" ]]; then + local owner_repo repo_api candidate_paths + owner_repo=$(echo "$github_url" | sed -E 's|https?://github.com/||') + repo_api="https://api.github.com/repos/${owner_repo}" + candidate_paths=( + "logo.png" + "icon.png" + "logo.svg" + "icon.svg" + "assets/logo.png" + "assets/icon.png" + ".github/logo.png" + ".github/icon.png" + ) + + for path in "${candidate_paths[@]}"; do + local file_api="${repo_api}/contents/${path}" + local download_url + download_url=$(curl -s "$file_api" | jq -r '.download_url // empty' 2>/dev/null || true) + if [[ -n "$download_url" ]]; then + if curl -sSL -o "$output_file" "$download_url" 2>/dev/null && [[ -s "$output_file" ]]; then + log_info "图标来自仓库: ${path}" + return 0 + fi + fi + done + fi # 尝试各个图标源 for source in "${ICON_SOURCES[@]}"; do @@ -327,9 +576,8 @@ download_icon() { fi done - log_warn "未找到图标,使用占位符" - # 创建简单的占位图标(1x1 像素 PNG) - echo -n "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" | base64 -d > "$output_file" + log_warn "未找到图标,请手动补充 logo.png" + return 1 } # 主函数 @@ -379,24 +627,70 @@ main() { ;; esac - # 创建输出目录 - local output_dir="${OUTPUT_BASE}/${APP_KEY}/${TAG:-latest}" - mkdir -p "$output_dir" + # 生成端口映射数组 + HOST_PORTS=() + CONTAINER_PORTS=() + PORT_ENV_KEYS=() + PORT_ENV_KEYS_USED=() + local i + for ((i=0; i<${#PORT_ENTRIES[@]}; i++)); do + local mapping + mapping=$(parse_port_entry "${PORT_ENTRIES[$i]}") + local host_port="${mapping%%:*}" + local container_port="${mapping##*:}" + HOST_PORTS+=("$host_port") + CONTAINER_PORTS+=("$container_port") + local envkey + envkey=$(map_port_envkey "$container_port" "$i") + # 保证 envKey 唯一 + if [[ " ${PORT_ENV_KEYS_USED[*]} " == *" ${envkey} "* ]]; then + envkey="${envkey}_${i}" + fi + PORT_ENV_KEYS+=("$envkey") + PORT_ENV_KEYS_USED+=("$envkey") + done - log_info "输出目录: $output_dir" + # 获取最新版本号 + VERSION_TAG=$(get_latest_tag) + if [[ -z "$VERSION_TAG" ]]; then + VERSION_TAG="$TAG" + fi + if [[ -z "$VERSION_TAG" ]]; then + VERSION_TAG="latest" + fi - # 生成文件 - generate_data_yml "${output_dir}/data.yml" - generate_docker_compose "${output_dir}/docker-compose.yml" - generate_readme "$output_dir" - download_icon "$APP_NAME" "${output_dir}/logo.png" + # 创建输出目录(latest + 具体版本) + local output_dir_latest="${OUTPUT_BASE}/${APP_KEY}/latest" + mkdir -p "$output_dir_latest" + + local output_dir_version="" + if [[ "$VERSION_TAG" != "latest" ]]; then + output_dir_version="${OUTPUT_BASE}/${APP_KEY}/${VERSION_TAG}" + mkdir -p "$output_dir_version" + else + log_warn "未能获取具体版本号,version 目录将使用 latest(请手动修正)" + fi + + log_info "输出目录: $output_dir_latest" + if [[ -n "$output_dir_version" ]]; then + log_info "输出目录: $output_dir_version" + fi + + # 生成 latest 版本文件 + generate_version_data_yml "${output_dir_latest}/data.yml" + generate_docker_compose "${output_dir_latest}/docker-compose.yml" "latest" + + # 生成具体版本文件 + if [[ -n "$output_dir_version" ]]; then + generate_version_data_yml "${output_dir_version}/data.yml" + generate_docker_compose "${output_dir_version}/docker-compose.yml" "$VERSION_TAG" + fi + + generate_readme "${OUTPUT_BASE}/${APP_KEY}" + download_icon "$APP_NAME" "${OUTPUT_BASE}/${APP_KEY}/logo.png" "${GITHUB:-}" # 生成上级目录的 data.yml generate_data_yml "${OUTPUT_BASE}/${APP_KEY}/data.yml" - cp "${output_dir}/logo.png" "${OUTPUT_BASE}/${APP_KEY}/logo.png" 2>/dev/null || true - cp "${output_dir}/README.md" "${OUTPUT_BASE}/${APP_KEY}/README.md" 2>/dev/null || true - cp "${output_dir}/README_en.md" "${OUTPUT_BASE}/${APP_KEY}/README_en.md" 2>/dev/null || true - # 输出结果 echo "" log_info "${GREEN}✓ 生成完成!${NC}"