diff --git a/skills/README.md b/skills/README.md
index 03d794c..23a47a8 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -1,200 +1,61 @@
# 1Panel App Builder
-快速生成符合 1Panel 本地应用商店规范的 APP 配置文件。
+这个目录提供 1Panel 本地应用商店打包辅助脚本。AI 使用入口是 `SKILL.md`,人工常用入口是下面几个脚本。
-## 功能特性
-
-- ✅ 支持多种输入源(GitHub、docker-compose、docker run、本地文件)
-- ✅ 自动抽取应用信息并生成标准化配置
-- ✅ 自动下载应用图标(支持多个图标源)
-- ✅ 生成中英文 README
-- ✅ 符合 1Panel 应用商店规范
-
-## 目录结构
-
-```
-1panel-app-builder/
-├── SKILL.md # 技能定义文件
-├── README.md # 使用文档
-├── templates/ # 配置模板
-│ ├── data.yml.tpl # 应用元数据模板
-│ └── docker-compose.yml.tpl # 编排文件模板
-├── scripts/ # 工具脚本
-│ ├── generate-app.sh # 主生成脚本
-│ └── download-icon.sh # 图标下载工具
-└── examples/ # 使用示例
- └── example-usage.md
-```
-
-## 快速开始
-
-### 1. 基本用法
+## 快速使用
```bash
-# GitHub 项目
-./scripts/generate-app.sh https://github.com/alist-org/alist
+cd /root/github/1Panel-Appstore/skills
-# docker-compose 文件链接
-./scripts/generate-app.sh https://raw.githubusercontent.com/.../docker-compose.yml
+# 从 compose 生成草稿,跳过图标下载
+./scripts/generate-app.sh --app-key my-app --name MyApp --version 1.2.3 --icon-mode skip ./docker-compose.yml
-# docker run 命令
-./scripts/generate-app.sh "docker run -d --name=alist -p 5244:5244 xhofe/alist:v3.45.0"
+# 下载或复用图标
+./scripts/download-icon.sh --mode cache-only my-app ../apps/my-app/logo.png
+./scripts/download-icon.sh --mode required --url https://example.com/logo.png my-app ../apps/my-app/logo.png
-# 本地文件
-./scripts/generate-app.sh ./my-docker-compose.yml
+# 验证应用
+./scripts/validate-app.sh ../apps/my-app
+
+# 验证 skills 工具链
+./tests/run_all.sh
```
-### 2. 输出示例
+## 生成脚本
-```
-./apps/alist/v3.45.0/
-├── data.yml # 应用元数据
-├── docker-compose.yml # 编排文件
-├── logo.png # 应用图标
-├── README.md # 中文简介
-└── README_en.md # 英文简介
+`scripts/generate-app.sh` 支持 GitHub URL、compose URL、本地 compose 文件和 `docker run` 命令。
+
+常用参数:
+
+```text
+--output
输出目录,默认 ./apps
+--app-key 指定应用目录名
+--name 指定应用显示名
+--version 指定具体版本目录和镜像 tag
+--icon-mode auto|required|skip|cache-only
+--icon-url 使用指定图标 URL
+--force 允许覆盖已有生成目录
+--dry-run 只解析并输出结果,不写文件
```
-### 3. 生成的配置文件
+## 图标策略
-#### data.yml 示例
+脚本不会创建占位图。图标模式:
-```yaml
-name: AList
-tags:
- - 实用工具
- - 云存储
-title: 支持多存储的文件列表程序和私人网盘
-description: 支持多存储的文件列表程序和私人网盘
-additionalProperties:
- key: alist
- name: AList
- tags:
- - Storage
- - Tool
- shortDescZh: 支持多存储的文件列表程序和私人网盘
- shortDescEn: Supporting multi-storage file listing program
- website: https://alist.nn.ci/
- github: https://github.com/alist-org/alist
- architectures:
- - amd64
- - arm64
-```
+- `auto`: 优先缓存,缺失时尝试网络源;找不到也不阻塞生成。
+- `skip`: 跳过图标处理,适合草稿生成。
+- `cache-only`: 只读 `skills/.cache/icons`,不访问网络。
+- `required`: 找不到有效图标时失败。
-#### docker-compose.yml 示例
+## 校验范围
-```yaml
-services:
- alist:
- container_name: ${CONTAINER_NAME}
- restart: always
- networks:
- - 1panel-network
- ports:
- - "${PANEL_APP_PORT_HTTP}:5244"
- volumes:
- - ./data/data:/opt/alist/data
- environment:
- - PUID=0
- - PGID=0
- - UMASK=022
- image: xhofe/alist:v3.45.0
- labels:
- createdBy: "Apps"
-networks:
- 1panel-network:
- external: true
-```
+`scripts/validate-app.sh` 会检查:
-## 高级用法
+- 应用目录结构和必需文件。
+- `additionalProperties.key` 是否等于目录名。
+- `PANEL_APP_PORT_*` 是否在版本 `data.yml` 中定义。
+- `latest/` 镜像是否使用 `latest` tag。
+- `logo.png` 是否疑似 HTML/XML 错误响应。
+- 1Panel 常见 compose 约束,如 `${CONTAINER_NAME}`、`1panel-network`、`createdBy: "Apps"`。
-### 指定输出目录
-
-```bash
-./scripts/generate-app.sh https://github.com/alist-org/alist ./my-apps
-```
-
-### 单独下载图标
-
-```bash
-./scripts/download-icon.sh alist ./logo.png 200
-```
-
-### 图标源优先级
-
-1. [Dashboard Icons](https://dashboardicons.com/icons)
-2. [Simple Icons](https://simpleicons.org/)
-3. [selfh.st Icons](https://selfh.st/icons/)
-
-## 依赖工具
-
-必需:
-- `bash` - 脚本执行环境
-- `curl` - HTTP 请求
-- `jq` - JSON 解析
-- `yq` - YAML 解析(可选,用于高级解析)
-
-可选:
-- `ImageMagick` (convert) - 图标尺寸调整
-- `sips` (macOS) - 图标尺寸调整
-- `tree` - 目录树显示
-
-## 配置模板说明
-
-### data.yml 模板变量
-
-| 变量 | 说明 | 示例 |
-|------|------|------|
-| `${APP_NAME}` | 应用名称 | AList |
-| `${APP_KEY}` | 应用键名 | alist |
-| `${TITLE_ZH}` | 中文标题 | 文件列表程序 |
-| `${DESC_ZH}` | 中文描述 | 支持多存储... |
-| `${TAG_1}` | 标签1 | 实用工具 |
-| `${WEBSITE}` | 官网 | https://alist.nn.ci/ |
-| `${GITHUB}` | GitHub | https://github.com/alist-org/alist |
-
-### docker-compose.yml 模板变量
-
-| 变量 | 说明 | 示例 |
-|------|------|------|
-| `${SERVICE_NAME}` | 服务名 | alist |
-| `${IMAGE}` | 镜像名 | xhofe/alist |
-| `${TAG}` | 镜像标签 | v3.45.0 |
-| `${PORT}` | 端口 | 5244 |
-
-## 下一步操作
-
-生成配置后:
-
-1. ✅ 检查 `data.yml` 中的应用信息是否准确
-2. ✅ 补充完善应用描述和标签
-3. ✅ 替换 `logo.png` 为合适的应用图标
-4. ✅ 测试 `docker-compose.yml` 是否正常工作
-5. ✅ 提交到 1Panel 应用商店仓库
-
-## 常见问题
-
-### Q: 图标下载失败怎么办?
-
-A: 手动从以下网站下载图标:
-- https://dashboardicons.com/icons?q=<应用名>
-- https://simpleicons.org/?q=<应用名>
-- https://selfh.st/icons/
-
-### Q: 如何处理多服务应用?
-
-A: 生成后手动编辑 `docker-compose.yml`,添加其他服务。
-
-### Q: 版本号如何确定?
-
-A: 默认从镜像标签提取,也可手动指定。
-
-## 参考资源
-
-- [1Panel 官方文档](https://1panel.cn/docs/)
-- [1Panel 应用商店仓库](https://github.com/1Panel-dev/appstore)
-- [Docker Compose 文档](https://docs.docker.com/compose/)
-
-## 许可证
-
-MIT License
+生成结果仍需要人工审查 metadata、README、架构、端口语义和多服务依赖。
diff --git a/skills/SKILL.md b/skills/SKILL.md
index ea1e815..946a2e2 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -1,451 +1,152 @@
---
name: 1panel-app-builder
-description: >
- Build 1Panel local app store configurations from Docker deployments. Use when:
- - User provides a GitHub project link and wants to add it to 1Panel app store
- - User has a docker-compose.yml or docker run command and needs 1Panel app format
- - User wants to create a local app for 1Panel panel
- - User mentions "1Panel app", "1Panel 应用商店", "本地应用", "app store", or similar
-
- Supports: GitHub repos, Docker Hub images, docker-compose files, docker run commands.
- Output: Complete app folder with data.yml, docker-compose.yml, logo.png, README.md
-
- Always use this skill for 1Panel app packaging - it knows the exact directory structure,
- variable naming conventions (PANEL_APP_PORT_HTTP, CONTAINER_NAME), and 1panel-network
- requirements that are easy to get wrong without guidance.
+description: Use when packaging Docker deployments as 1Panel local app store apps, including GitHub projects, docker-compose.yml files, docker run commands, app metadata, version directories, icons, README files, and 1Panel validation.
---
-# 1Panel App Store Builder
+# 1Panel App Builder
-Transform Docker deployments into 1Panel-compatible local app store packages.
+Build or review 1Panel local app store packages from Docker deployments.
-## Understanding 1Panel App Structure
+## When To Use
-A valid 1Panel app follows this directory structure:
+Use this skill when the user asks to:
-```
-app-key/ # App identifier (lowercase, hyphenated)
-├── logo.png # App icon (64x64 or 128x128 recommended)
-├── data.yml # App metadata (top-level)
-├── README.md # Chinese documentation
-├── README_en.md # English documentation (optional)
-└── 1.0.0/ # Version directory (semver or "latest")
- ├── data.yml # Parameter definitions (form fields)
- ├── docker-compose.yml # Compose file with variable substitution
- ├── data/ # Persistent data directory
- └── scripts/ # Optional scripts
- └── upgrade.sh # Upgrade script
+- Add or package a 1Panel local app store app.
+- Convert a GitHub project, `docker-compose.yml`, or `docker run` command into `apps//`.
+- Fix 1Panel app metadata, port variables, compose files, README files, icons, or version directories.
+- Validate an app package before commit.
+
+For detailed real app examples, read `references/1panel-examples.md` only when needed.
+
+## Required Shape
+
+```text
+apps//
+├── data.yml
+├── logo.png
+├── README.md
+├── README_en.md
+└── /
+ ├── data.yml
+ ├── docker-compose.yml
+ └── data/
```
-## Step-by-Step Workflow
+Some apps also include `latest/`. When both `latest/` and a concrete version exist, `latest/` must use image tag `latest`, and the concrete version directory should use the pinned tag.
-### Step 1: Gather Source Information
+## Workflow
-Based on user input type, extract Docker deployment details:
+1. Inspect the source deployment.
+ - GitHub: inspect README/deploy docs and registry image.
+ - Compose: parse services, image tags, ports, volumes, environment, dependencies.
+ - Docker run: parse image, `--name`, `-p`, `-v`, and `-e`.
+2. Choose a stable app key.
+ - Lowercase, hyphenated, directory-safe.
+ - `additionalProperties.key` must match the directory name.
+3. Generate or edit app files.
+ - Prefer `latest/` plus a concrete version when a concrete image tag is known.
+ - Preserve source volumes and environment unless they conflict with 1Panel conventions.
+4. Resolve a real icon.
+ - Do not create placeholders.
+ - Use `--icon-mode skip` for drafts, `cache-only` for offline work, and `required` when an icon is mandatory.
+5. Validate before delivery.
+ - Run `skills/scripts/validate-app.sh ./apps/`.
+ - For Skill script changes, run `skills/tests/run_all.sh`.
-**From GitHub Link:**
-1. Fetch the GitHub repository README.md
-2. Look for Docker installation instructions (docker run, docker-compose)
-3. Extract: image name, ports, volumes, environment variables
-4. Note: project name, description, official website, supported architectures
+## 1Panel Rules
-**From docker-compose.yml:**
-1. Parse all services defined
-2. Extract: image versions, port mappings, volume mounts, environment variables
-3. Identify the main service (typically the one with web UI)
+- `container_name` should be `${CONTAINER_NAME}`.
+- Services should use `restart: always`.
+- Use external `1panel-network`.
+- Port mappings should use `PANEL_APP_PORT_*` variables defined in the version `data.yml`.
+- Persistent mounts should prefer relative `./data/...` paths.
+- Add `labels: createdBy: "Apps"`.
+- Keep metadata tags aligned with top-level `data.yaml`.
-**From docker run command:**
-1. Parse flags: `-p` (ports), `-v` (volumes), `-e` (env vars), `--name`
-2. Extract the image name and tag
-3. Identify exposed ports and data directories
+Preferred port variables:
-### 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
-- Use hyphens for multi-word names
-- Keep it short but descriptive
-- Examples: `alist`, `it-tools`, `n8n-zh`, `lobe-chat-data`
-
-**Required Metadata (top-level data.yml):**
-```yaml
-name: AppName # Display name (can have capitals)
-tags: # Chinese tags for categorization
- - 开发工具
- - 实用工具
-title: Brief Chinese description
-description: Same as title
-additionalProperties:
- key: app-key # Must match directory name
- name: AppName # Same as name above
- tags: # English tags
- - DevTool
- - Utility
- shortDescZh: Chinese description
- shortDescEn: English description
- description:
- en: Full English description
- zh: Full Chinese description
- # Add more languages if available: ja, ko, ru, pt-br, ms, zh-Hant
- type: website # or "runtime" for databases, "tool" for CLI tools
- crossVersionUpdate: true # Usually true
- limit: 0 # 0 = unlimited instances
- recommend: 0 # 0-100, higher = more recommended
- website: https://example.com # Official website
- github: https://github.com/org/repo
- document: https://docs.example.com
- architectures: # Supported CPU architectures
- - amd64
- - arm64
- # Add: arm/v7, arm/v6, s390x if supported
+```text
+PANEL_APP_PORT_HTTP
+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
```
-### Step 4: Define Parameters (version/data.yml)
+## Scripts
-Parameters become UI form fields in 1Panel. Define user-configurable values:
+Run from `/root/github/1Panel-Appstore/skills` unless noted.
-```yaml
-additionalProperties:
- formFields:
- # Port parameter example
- - default: 8080
- edit: true
- envKey: PANEL_APP_PORT_HTTP # Variable name used in docker-compose
- labelEn: Web Port
- labelZh: Web端口
- required: true
- rule: paramPort # Validation rule
- type: number # Field type
- label: # Multi-language labels
- en: Web Port
- zh: Web端口
- ja: Webポート
- ko: Web 포트
-
- # Text parameter example (for API keys, passwords, etc.)
- - default: ""
- edit: true
- envKey: API_KEY
- labelEn: API Key
- labelZh: API密钥
- required: false
- rule: paramCommon
- type: text
-
- # Password parameter example
- - default: "changeme"
- edit: true
- envKey: ADMIN_PASSWORD
- labelEn: Admin Password
- labelZh: 管理员密码
- required: true
- rule: paramCommon
- type: password
-
- # Select parameter example
- - default: "sqlite"
- edit: true
- envKey: DATABASE_TYPE
- labelEn: Database Type
- labelZh: 数据库类型
- required: true
- type: select
- values:
- - label: SQLite
- value: sqlite
- - label: PostgreSQL
- value: postgres
-```
-
-**Parameter Types:**
-- `number`: For ports, counts
-- `text`: For API keys, URLs, names
-- `password`: For secrets (masked input)
-- `select`: For dropdown choices
-- `boolean`: For toggle switches
-
-**Validation Rules:**
-- `paramPort`: Valid port number (1-65535)
-- `paramCommon`: Non-empty string
-- `paramExtUrl`: Valid URL format
-- Empty string: No validation
-
-### 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:
-
-```yaml
-services:
- app-name:
- container_name: ${CONTAINER_NAME}
- restart: always
- networks:
- - 1panel-network
- ports:
- - "${PANEL_APP_PORT_HTTP}:8080"
- volumes:
- - ./data:/app/data
- environment:
- - TZ=Asia/Shanghai
- image: org/image:tag
- labels:
- createdBy: "Apps"
-networks:
- 1panel-network:
- external: true
-```
-
-**Critical Rules:**
-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 version/data.yml
-5. Volume paths use relative `./data/` for persistence
-6. Add `labels: createdBy: "Apps"`
-7. Keep original environment variables but use defaults
-
-**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
-# AppName
-
-简短描述应用的功能和特点。
-
-## 功能特点
-
-- 特点1
-- 特点2
-- 特点3
-
-## 使用说明
-
-### 默认端口
-
-- Web界面: 8080
-
-### 默认账号密码
-
-- 用户名: admin
-- 密码: changeme (请在部署后立即修改)
-
-### 数据目录
-
-应用数据存储在 `./data` 目录。
-
-## 相关链接
-
-- 官方网站: https://example.com
-- GitHub: https://github.com/org/repo
-- 文档: https://docs.example.com
-```
-
-**README_en.md (English, optional):**
-```markdown
-# AppName
-
-Brief description of the application.
-
-## Features
-
-- Feature 1
-- Feature 2
-- Feature 3
-
-## Usage
-
-### Default Port
-
-- Web UI: 8080
-
-### Default Credentials
-
-- Username: admin
-- Password: changeme (change after deployment)
-
-## Links
-
-- Website: https://example.com
-- GitHub: https://github.com/org/repo
-```
-
-### Step 7: Download App Icon
-
-**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/
-
-**Do not** create a placeholder or incorrect icon. If not found, warn and leave `logo.png` for manual replacement.
-
-### Step 8: Create Data Directory Structure
-
-Create the `data/` directory inside the version folder:
-```bash
-mkdir -p app-key/version/data
-```
-
-This directory will be mounted as a volume for persistent data.
-
-## Quality Checklist
-
-Before delivering the app package, verify:
-
-- [ ] App key is lowercase with hyphens only
-- [ ] All required fields in top-level data.yml are present
-- [ ] `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; avoid only `latest`
-
-## Common Patterns
-
-### Database Applications
-```yaml
-# For apps that need a database (PostgreSQL, MySQL, etc.)
-# Include database as a separate service in docker-compose
-services:
- app:
- depends_on:
- - db
- db:
- image: postgres:15
- volumes:
- - ./data/db:/var/lib/postgresql/data
- environment:
- - POSTGRES_DB=appdb
- - POSTGRES_USER=appuser
- - POSTGRES_PASSWORD=${DB_PASSWORD}
-```
-
-### Multi-Service Applications
-```yaml
-# For apps with multiple services (frontend + backend, etc.)
-services:
- frontend:
- image: org/frontend:tag
- ports:
- - "${PANEL_APP_PORT_HTTP}:80"
- backend:
- image: org/backend:tag
- ports:
- - "${PANEL_APP_PORT_API}:8080"
-```
-
-### Applications with Reverse Proxy
-```yaml
-# For apps that don't expose ports directly
-services:
- app:
- # No ports section - accessed through 1Panel's reverse proxy
- expose:
- - "8080"
- networks:
- - 1panel-network
-```
-
-## Automation Scripts
-
-Use the provided scripts for faster workflow:
-
-### generate-app.sh - 自动生成应用配置
+Generate a draft:
```bash
-# GitHub 项目
-./scripts/generate-app.sh https://github.com/alist-org/alist
-
-# docker-compose 文件
-./scripts/generate-app.sh ./docker-compose.yml
-
-# docker run 命令
-./scripts/generate-app.sh "docker run -d --name=nginx -p 80:80 nginx:latest"
+./scripts/generate-app.sh --app-key my-app --name MyApp --version 1.2.3 --icon-mode skip ./docker-compose.yml
```
-### download-icon.sh - 下载应用图标
+Useful generation options:
+
+```text
+--output Output base directory. Default: ./apps
+--app-key Override app directory key.
+--name Override display name.
+--version Override concrete version and image tag.
+--icon-mode auto|required|skip|cache-only
+--icon-url Download icon from a known URL.
+--force Allow overwriting an existing generated directory.
+--dry-run Print parsed values without writing files.
+```
+
+Download an icon:
```bash
-./scripts/download-icon.sh redis ./logo.png 200
+./scripts/download-icon.sh --mode cache-only redis ./logo.png
+./scripts/download-icon.sh --mode required --url https://example.com/logo.png myapp ./logo.png
```
-### validate-app.sh - 验证配置完整性
+Validate an app:
```bash
-./scripts/validate-app.sh ./apps/myapp
+./scripts/validate-app.sh ../apps/
```
-## Reference Files
+Validate Skill tooling:
-- `references/1panel-examples.md` - 完整的真实应用配置示例(AList、NocoDB等)
-- `examples/example-usage.md` - 使用示例和工作流程
+```bash
+./tests/run_all.sh
+```
-## Troubleshooting
+## Icon Policy
-**Issue**: App won't start
-- Check if ports are already in use
-- Verify volume permissions
-- Check container logs: `docker logs ${CONTAINER_NAME}`
+Icon lookup order:
-**Issue**: Data not persisting
-- Ensure volumes are correctly mapped to `./data/`
-- Check directory permissions
+1. Known explicit URL (`--icon-url`).
+2. Local cache under `skills/.cache/icons`.
+3. Dashboard Icons.
+4. Simple Icons.
+5. selfh.st Icons.
-**Issue**: Environment variables not working
-- Verify variable names match between data.yml and docker-compose.yml
-- Check default values are appropriate
+Missing icons are acceptable for drafts only. For final app delivery, leave `logo.png` absent and tell the user what is missing instead of inventing an inaccurate image.
-**Issue**: 图标下载失败
-- 从以下网站手动下载:
- - https://dashboardicons.com/icons?q={app-name}
- - https://simpleicons.org/?q={app-name}
- - https://selfh.st/icons/
+## Validation Gate
+
+Before considering an app ready:
+
+- Run `./scripts/validate-app.sh ../apps/`.
+- Confirm `latest/` uses `:latest`.
+- Confirm concrete version directories use matching image tags or document the exception.
+- Confirm every compose `PANEL_APP_PORT_*` variable exists in the version `data.yml`.
+- Inspect generated README and metadata manually; the generator is a starting point, not an authority.
+
+## Common Mistakes
+
+- Keeping a placeholder `logo.png`.
+- Using a host absolute volume path when `./data/...` would work.
+- Forgetting to define a port variable used by `docker-compose.yml`.
+- Letting `additionalProperties.key` drift from the app directory name.
+- Treating generated metadata, descriptions, tags, and architectures as final without review.
diff --git a/skills/evals/evals.json b/skills/evals/evals.json
index 232ca5b..b32306e 100644
--- a/skills/evals/evals.json
+++ b/skills/evals/evals.json
@@ -4,19 +4,19 @@
{
"id": 1,
"prompt": "帮我把这个项目打包成1Panel本地应用:https://github.com/0xJacky/nginx-ui 这是一个Nginx配置管理工具,用docker部署,默认端口是9000",
- "expected_output": "Create a complete 1Panel app package for nginx-ui with proper directory structure, data.yml metadata, docker-compose.yml with variable substitution, README files, and placeholder logo",
+ "expected_output": "Create a 1Panel app package draft for nginx-ui with proper directory structure, data.yml metadata, docker-compose.yml variable substitution, README files, and either a real logo.png or a clear note that the icon is missing; never create a placeholder logo",
"files": []
},
{
"id": 2,
"prompt": "我有一个docker-compose.yml文件,内容如下:\n```yaml\nversion: '3'\nservices:\n memos:\n image: neosmemo/memos:latest\n container_name: memos\n ports:\n - \"5230:5230\"\n volumes:\n - ./memos_data:/var/opt/memos\n restart: always\n```\n帮我生成1Panel应用商店的配置",
- "expected_output": "Parse the docker-compose file and generate 1Panel app package for memos with converted docker-compose.yml using PANEL_APP_PORT_HTTP variable, proper data.yml with port parameter definition, and supporting files",
+ "expected_output": "Parse the docker-compose file and generate a 1Panel app package for memos with PANEL_APP_PORT_HTTP, matching version data.yml port field, preserved volume mapping, and validation-ready supporting files",
"files": []
},
{
"id": 3,
"prompt": "docker run -d --name umami -p 3000:3000 -e DATABASE_URL=postgresql://user:pass@db:5432/umami ghcr.io/umami-software/umami:postgresql-latest\n请帮我把这个umami网站统计工具做成1Panel应用",
- "expected_output": "Parse the docker run command, extract port 3000, environment variable DATABASE_URL, and generate 1Panel app package with appropriate parameter fields for database URL configuration",
+ "expected_output": "Parse the docker run command, extract port 3000 and DATABASE_URL, preserve the environment setting, and generate a 1Panel app package with appropriate form fields and validation guidance",
"files": []
}
]
diff --git a/skills/examples/example-usage.md b/skills/examples/example-usage.md
index eaf530b..94a1235 100644
--- a/skills/examples/example-usage.md
+++ b/skills/examples/example-usage.md
@@ -1,237 +1,71 @@
# 使用示例
-## 示例 1:从 GitHub 项目生成
+## 从本地 compose 生成草稿
```bash
-# 输入
-./scripts/generate-app.sh https://github.com/alist-org/alist
+cd /root/github/1Panel-Appstore/skills
-# 输出
-./apps/alist/v3.45.0/
+./scripts/generate-app.sh \
+ --app-key demo-app \
+ --name DemoApp \
+ --version 1.25.3 \
+ --icon-mode skip \
+ --output ../apps \
+ /tmp/demo-compose.yml
+```
+
+生成结构:
+
+```text
+../apps/demo-app/
├── data.yml
-├── docker-compose.yml
-├── logo.png
├── README.md
-└── README_en.md
+├── README_en.md
+├── latest/
+│ ├── data.yml
+│ └── docker-compose.yml
+└── 1.25.3/
+ ├── data.yml
+ └── docker-compose.yml
```
-### 生成的 data.yml
+`latest/docker-compose.yml` 使用 `image: ...:latest`,`1.25.3/docker-compose.yml` 使用 `image: ...:1.25.3`。
-```yaml
-name: AList
-tags:
- - 实用工具
- - 云存储
-title: AList - 文件列表程序
-description: 支持多存储的文件列表程序和私人网盘
-additionalProperties:
- key: alist
- name: AList
- tags:
- - Storage
- - Tool
- shortDescZh: 支持多存储的文件列表程序和私人网盘
- shortDescEn: Supporting multi-storage file listing program
- website: https://alist.nn.ci/
- github: https://github.com/alist-org/alist
-```
-
-### 生成的 docker-compose.yml
-
-```yaml
-services:
- alist:
- container_name: ${CONTAINER_NAME}
- restart: always
- networks:
- - 1panel-network
- ports:
- - "${PANEL_APP_PORT_HTTP}:5244"
- volumes:
- - ./data/data:/opt/alist/data
- environment:
- - PUID=0
- - PGID=0
- - UMASK=022
- image: xhofe/alist:v3.45.0
- labels:
- createdBy: "Apps"
-networks:
- 1panel-network:
- external: true
-```
-
----
-
-## 示例 2:从 docker run 命令生成
+## docker run 输入
```bash
-# 输入
-./scripts/generate-app.sh "docker run -d --name=nginx -p 80:80 nginx:latest"
-
-# 输出
-./apps/nginx/latest/
-├── data.yml
-├── docker-compose.yml
-├── logo.png
-├── README.md
-└── README_en.md
+./scripts/generate-app.sh \
+ --app-key nginx-demo \
+ --name NginxDemo \
+ --version 1.25.3 \
+ --icon-mode cache-only \
+ "docker run -d --name nginx-demo -p 8080:80 -v ./data/html:/usr/share/nginx/html nginx:1.25.3"
```
-### 生成的 docker-compose.yml
+脚本会解析:
-```yaml
-services:
- nginx:
- container_name: ${CONTAINER_NAME}
- restart: always
- networks:
- - 1panel-network
- ports:
- - "${PANEL_APP_PORT_HTTP}:80"
- volumes:
- - ./data/data:/app/data
- environment:
- - PUID=0
- - PGID=0
- - UMASK=022
- image: nginx:latest
- labels:
- createdBy: "Apps"
-networks:
- 1panel-network:
- external: true
-```
+- `--name` 作为服务名候选。
+- `-p` 生成 `PANEL_APP_PORT_*` 表单字段。
+- `-v` 保留到 compose 的 `volumes`。
+- 镜像 tag 作为版本候选;显式 `--version` 优先。
----
-
-## 示例 3:从 docker-compose 文件链接生成
+## 图标处理
```bash
-# 输入
-./scripts/generate-app.sh https://raw.githubusercontent.com/portainer/portainer/develop/docker-compose.yml
+# 只使用缓存,不访问网络
+./scripts/download-icon.sh --mode cache-only nginx ../apps/nginx-demo/logo.png
-# 输出
-./apps/portainer/latest/
-├── data.yml
-├── docker-compose.yml
-├── logo.png
-├── README.md
-└── README_en.md
+# 已知准确图标 URL 时强制下载,失败则退出非零
+./scripts/download-icon.sh --mode required --url https://example.com/nginx.png nginx ../apps/nginx-demo/logo.png
```
----
+脚本不会创建占位图。找不到真实图标时,应保留缺失状态并在交付说明中指出。
-## 示例 4:从本地文件生成
+## 验证
```bash
-# 创建测试文件
-cat > /tmp/test-compose.yml << 'EOF'
-version: '3'
-services:
- redis:
- image: redis:7-alpine
- ports:
- - "6379:6379"
- volumes:
- - redis-data:/data
-
-volumes:
- redis-data:
-EOF
-
-# 运行脚本
-./scripts/generate-app.sh /tmp/test-compose.yml ./my-apps
-
-# 输出
-./my-apps/redis/7-alpine/
-├── data.yml
-├── docker-compose.yml
-├── logo.png
-├── README.md
-└── README_en.md
+./scripts/validate-app.sh ../apps/demo-app
+./tests/run_all.sh
```
----
-
-## 示例 5:单独下载图标
-
-```bash
-# 下载 Redis 图标
-./scripts/download-icon.sh redis ./redis-logo.png 200
-
-# 输出
-✓ 从 Simple Icons 下载成功
-✓ 图标下载完成: ./redis-logo.png
-```
-
----
-
-## 示例 6:自定义输出目录
-
-```bash
-# 输出到指定目录
-./scripts/generate-app.sh https://github.com/portainer/portainer ~/my-1panel-apps
-
-# 输出
-/Users/username/my-1panel-apps/portainer/latest/
-├── data.yml
-├── docker-compose.yml
-├── logo.png
-├── README.md
-└── README_en.md
-```
-
----
-
-## 完整工作流程示例
-
-```bash
-# 1. 生成应用配置
-./scripts/generate-app.sh https://github.com/alist-org/alist
-
-# 2. 查看生成的文件
-cd ./apps/alist/v3.45.0
-cat data.yml
-cat docker-compose.yml
-
-# 3. 手动优化配置
-# - 补充应用描述
-# - 添加合适的标签
-# - 替换 logo.png
-
-# 4. 测试 docker-compose
-docker-compose up -d
-
-# 5. 确认无误后,提交到应用商店
-git add .
-git commit -m "feat: add alist app"
-git push
-```
-
----
-
-## 故障排除
-
-### 问题:图标下载失败
-
-```bash
-# 手动下载图标
-# 访问:https://dashboardicons.com/icons?q=alist
-# 下载后保存为 logo.png
-```
-
-### 问题:端口冲突
-
-```bash
-# 修改 docker-compose.yml 中的端口映射
-ports:
- - "${PANEL_APP_PORT_HTTP}:5244" # 修改此处
-```
-
-### 问题:镜像版本不对
-
-```bash
-# 手动编辑 docker-compose.yml
-image: xhofe/alist:v3.45.0 # 修改版本号
-```
+`validate-app.sh` 负责结构和常见规则检查;仍需要人工检查 README、分类标签、架构支持、默认端口和多服务依赖是否合理。
diff --git a/skills/scripts/download-icon.sh b/skills/scripts/download-icon.sh
index 64cbfd6..3e10d20 100755
--- a/skills/scripts/download-icon.sh
+++ b/skills/scripts/download-icon.sh
@@ -14,6 +14,9 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
+# 路径配置
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
# 图标源配置
DASHBOARD_ICONS="https://dashboardicons.com/icons"
SIMPLE_ICONS="https://cdn.simpleicons.org"
@@ -21,6 +24,12 @@ SELFHST_ICONS="https://selfh.st/icons"
# 默认输出尺寸
DEFAULT_SIZE=200
+MODE="auto"
+CACHE_DIR="${ICON_CACHE_DIR:-${SCRIPT_DIR}/../.cache/icons}"
+ICON_URL=""
+CONNECT_TIMEOUT="${ICON_CONNECT_TIMEOUT:-3}"
+MAX_TIME="${ICON_MAX_TIME:-8}"
+RETRY="${ICON_RETRY:-1}"
# 打印帮助
print_help() {
@@ -28,13 +37,23 @@ print_help() {
${BLUE}Icon Downloader${NC} - 从多个图标源下载应用图标
${YELLOW}用法:${NC}
- $0 <应用名称> [输出文件] [尺寸]
+ $0 [选项] <应用名称> [输出文件] [尺寸]
${YELLOW}参数:${NC}
应用名称 - 应用的名称(如 alist, nginx, redis)
输出文件 - 图标保存路径(默认: ./logo.png)
尺寸 - 图标尺寸(默认: 200x200)
+${YELLOW}选项:${NC}
+ --mode auto|required|skip|cache-only
+ auto: 优先缓存,缺失时尝试下载,未找到不创建占位图
+ required: 找不到图标时返回失败
+ skip: 跳过图标下载
+ cache-only: 只使用缓存,不访问网络
+ --cache-dir <目录>
+ 图标缓存目录(默认: skills/.cache/icons)
+ --url 从指定 URL 下载图标
+
${YELLOW}图标源优先级:${NC}
1. Dashboard Icons (dashboardicons.com)
2. Simple Icons (simpleicons.org)
@@ -52,13 +71,59 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+normalize_name() {
+ echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--' | tr -cd 'a-z0-9.-'
+}
+
+valid_icon_file() {
+ local file="$1"
+ [[ -s "$file" ]] || return 1
+ if head -c 512 "$file" | grep -qiE '|AccessDenied'; then
+ return 1
+ fi
+ return 0
+}
+
+copy_from_cache() {
+ local app_name="$1"
+ local output="$2"
+ local name_lower
+ name_lower=$(normalize_name "$app_name")
+
+ local ext cached
+ for ext in png svg webp jpg jpeg; do
+ cached="${CACHE_DIR}/${name_lower}.${ext}"
+ if valid_icon_file "$cached"; then
+ mkdir -p "$(dirname "$output")"
+ cp "$cached" "$output"
+ log_info "✓ 使用缓存图标: $cached"
+ return 0
+ fi
+ done
+ return 1
+}
+
+save_to_cache() {
+ local app_name="$1"
+ local file="$2"
+ local name_lower
+ name_lower=$(normalize_name "$app_name")
+
+ mkdir -p "$CACHE_DIR"
+ cp "$file" "${CACHE_DIR}/${name_lower}.png" 2>/dev/null || true
+}
+
# 下载并调整图标尺寸
download_and_resize() {
local url="$1"
local output="$2"
local size="$3"
+ local tmp
+ tmp="$(mktemp)"
- if curl -sSL -o "$output" "$url" 2>/dev/null && [[ -s "$output" ]]; then
+ if curl -fsSL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --retry "$RETRY" -o "$tmp" "$url" 2>/dev/null && valid_icon_file "$tmp"; then
+ mkdir -p "$(dirname "$output")"
+ mv "$tmp" "$output"
# 检查是否安装了 ImageMagick 或 sips (macOS)
if command -v convert &>/dev/null; then
convert "$output" -resize "${size}x${size}" "$output" 2>/dev/null || true
@@ -67,6 +132,7 @@ download_and_resize() {
fi
return 0
fi
+ rm -f "$tmp"
return 1
}
@@ -77,20 +143,32 @@ try_download_icon() {
local size="$3"
local name_lower
- name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
+ name_lower=$(normalize_name "$app_name")
log_info "尝试下载图标: $app_name"
+ if [[ -n "$ICON_URL" ]]; then
+ log_info "尝试指定图标 URL..."
+ if download_and_resize "$ICON_URL" "$output" "$size"; then
+ save_to_cache "$app_name" "$output"
+ log_info "✓ 从指定 URL 下载成功"
+ return 0
+ fi
+ return 1
+ fi
+
# 1. Dashboard Icons
log_info "尝试 Dashboard Icons..."
local dashboard_url="${DASHBOARD_ICONS}/${name_lower}.png"
if download_and_resize "$dashboard_url" "$output" "$size"; then
+ save_to_cache "$app_name" "$output"
log_info "✓ 从 Dashboard Icons 下载成功"
return 0
fi
# 尝试带 -dark 后缀
if download_and_resize "${DASHBOARD_ICONS}/${name_lower}-dark.png" "$output" "$size"; then
+ save_to_cache "$app_name" "$output"
log_info "✓ 从 Dashboard Icons (dark) 下载成功"
return 0
fi
@@ -99,18 +177,20 @@ try_download_icon() {
log_info "尝试 Simple Icons..."
local simple_url="${SIMPLE_ICONS}/${name_lower}"
if download_and_resize "$simple_url" "$output" "$size"; then
+ save_to_cache "$app_name" "$output"
log_info "✓ 从 Simple Icons 下载成功"
return 0
fi
# Simple Icons 也支持 SVG
- if curl -sSL -o "${output%.png}.svg" "${SIMPLE_ICONS}/${name_lower}" 2>/dev/null && [[ -s "${output%.png}.svg" ]]; then
+ if curl -fsSL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --retry "$RETRY" -o "${output%.png}.svg" "${SIMPLE_ICONS}/${name_lower}" 2>/dev/null && valid_icon_file "${output%.png}.svg"; then
log_info "✓ 从 Simple Icons 下载 SVG 成功"
# 如果有 SVG,尝试转换
if command -v convert &>/dev/null; then
convert "${output%.png}.svg" -resize "${size}x${size}" "$output" 2>/dev/null || true
if [[ -s "$output" ]]; then
rm -f "${output%.png}.svg"
+ save_to_cache "$app_name" "$output"
return 0
fi
fi
@@ -120,6 +200,7 @@ try_download_icon() {
log_info "尝试 selfh.st Icons..."
local selfhst_url="${SELFHST_ICONS}/${name_lower}.png"
if download_and_resize "$selfhst_url" "$output" "$size"; then
+ save_to_cache "$app_name" "$output"
log_info "✓ 从 selfh.st Icons 下载成功"
return 0
fi
@@ -127,43 +208,58 @@ try_download_icon() {
return 1
}
-# 创建占位图标
-create_placeholder() {
- local output="$1"
- local size="$2"
+parse_args() {
+ POSITIONAL=()
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --mode)
+ MODE="${2:-}"
+ shift 2
+ ;;
+ --cache-dir)
+ CACHE_DIR="${2:-}"
+ shift 2
+ ;;
+ --url)
+ ICON_URL="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ print_help
+ exit 0
+ ;;
+ --)
+ shift
+ POSITIONAL+=("$@")
+ break
+ ;;
+ -*)
+ log_error "未知选项: $1"
+ exit 2
+ ;;
+ *)
+ POSITIONAL+=("$1")
+ shift
+ ;;
+ esac
+ done
- log_warn "创建占位图标"
-
- # 使用 ImageMagick 创建简单的占位图标
- if command -v convert &>/dev/null; then
- convert -size "${size}x${size}" xc:'#4A90E2' \
- -gravity center \
- -pointsize 48 \
- -fill white \
- -annotate 0 "?" \
- "$output" 2>/dev/null
- return $?
- fi
-
- # macOS sips 方式
- if command -v sips &>/dev/null; then
- # 创建一个简单的 1x1 像素 PNG 然后放大
- local temp_file="/tmp/placeholder_${RANDOM}.png"
- echo -n "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" | base64 -d > "$temp_file"
- sips -z "$size" "$size" "$temp_file" --out "$output" &>/dev/null
- rm -f "$temp_file"
- return $?
- fi
-
- # 最简单的占位符
- echo -n "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" | base64 -d > "$output"
+ case "$MODE" in
+ auto|required|skip|cache-only) ;;
+ *)
+ log_error "无效模式: $MODE"
+ exit 2
+ ;;
+ esac
}
# 主函数
main() {
- local app_name="${1:-}"
- local output="${2:-./logo.png}"
- local size="${3:-$DEFAULT_SIZE}"
+ parse_args "$@"
+
+ local app_name="${POSITIONAL[0]:-}"
+ local output="${POSITIONAL[1]:-./logo.png}"
+ local size="${POSITIONAL[2]:-$DEFAULT_SIZE}"
# 参数检查
if [[ -z "$app_name" ]] || [[ "$app_name" == "-h" ]] || [[ "$app_name" == "--help" ]]; then
@@ -171,10 +267,19 @@ main() {
exit 0
fi
- # 确保输出目录存在
- local output_dir
- output_dir=$(dirname "$output")
- mkdir -p "$output_dir"
+ if [[ "$MODE" == "skip" ]]; then
+ log_info "跳过图标下载: $app_name"
+ exit 0
+ fi
+
+ if copy_from_cache "$app_name" "$output"; then
+ exit 0
+ fi
+
+ if [[ "$MODE" == "cache-only" ]]; then
+ log_warn "缓存中未找到图标: $app_name"
+ exit 0
+ fi
# 尝试下载
if try_download_icon "$app_name" "$output" "$size"; then
@@ -182,13 +287,16 @@ main() {
exit 0
fi
- # 下载失败,创建占位符
- create_placeholder "$output" "$size"
- log_warn "未找到图标,已创建占位符: $output"
+ rm -f "$output" "${output%.png}.svg"
+ log_warn "未找到图标,未创建占位图: $output"
log_info "请手动从以下网站下载合适的图标:"
echo " - https://dashboardicons.com/icons?q=${app_name}"
echo " - https://simpleicons.org/?q=${app_name}"
echo " - https://selfh.st/icons/"
+
+ if [[ "$MODE" == "required" ]]; then
+ exit 1
+ fi
}
# 执行主函数
diff --git a/skills/scripts/generate-app.sh b/skills/scripts/generate-app.sh
old mode 100644
new mode 100755
index db43b13..5894e14
--- a/skills/scripts/generate-app.sh
+++ b/skills/scripts/generate-app.sh
@@ -18,12 +18,18 @@ NC='\033[0m' # No Color
# 默认配置
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="${SCRIPT_DIR}/../templates"
-OUTPUT_BASE="${2:-./apps}"
-ICON_SOURCES=(
- "https://dashboardicons.com/icons"
- "https://simpleicons.org/icons"
- "https://selfh.st/icons"
-)
+OUTPUT_BASE="./apps"
+ICON_MODE="auto"
+ICON_URL=""
+ICON_CACHE_DIR="${SCRIPT_DIR}/../.cache/icons"
+USER_APP_KEY=""
+USER_APP_NAME=""
+USER_VERSION=""
+FORCE=false
+DRY_RUN=false
+POSITIONAL=()
+SERVICE_VOLUMES=()
+SERVICE_ENV=()
# 打印帮助
print_help() {
@@ -31,7 +37,7 @@ print_help() {
${BLUE}1Panel App Builder${NC} - 快速生成 1Panel 应用配置
${YELLOW}用法:${NC}
- $0 <输入源> [输出目录]
+ $0 [选项] <输入源> [输出目录]
${YELLOW}输入源支持:${NC}
1. GitHub 项目链接
@@ -59,6 +65,17 @@ ${YELLOW}示例:${NC}
$0 https://github.com/alist-org/alist
$0 ./my-docker-compose.yml ./my-apps
$0 "docker run -d --name nginx -p 80:80 nginx:latest"
+ $0 --app-key demo-app --name DemoApp --version 1.2.3 --icon-mode skip ./docker-compose.yml
+
+${YELLOW}选项:${NC}
+ --output <目录> 输出目录(默认: ./apps)
+ --app-key 显式指定应用目录名
+ --name <名称> 显式指定应用显示名
+ --version <版本> 显式指定具体版本目录和镜像 tag
+ --icon-mode <模式> auto|required|skip|cache-only
+ --icon-url 使用指定图标 URL
+ --force 允许覆盖已有输出目录
+ --dry-run 只解析和打印结果,不写文件
EOF
}
@@ -67,6 +84,79 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+normalize_app_key() {
+ echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--' | tr -cd 'a-z0-9.-'
+}
+
+parse_args() {
+ POSITIONAL=()
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --output|-o)
+ OUTPUT_BASE="${2:-}"
+ shift 2
+ ;;
+ --app-key)
+ USER_APP_KEY="${2:-}"
+ shift 2
+ ;;
+ --name)
+ USER_APP_NAME="${2:-}"
+ shift 2
+ ;;
+ --version)
+ USER_VERSION="${2:-}"
+ shift 2
+ ;;
+ --icon-mode)
+ ICON_MODE="${2:-}"
+ shift 2
+ ;;
+ --icon-url)
+ ICON_URL="${2:-}"
+ shift 2
+ ;;
+ --force)
+ FORCE=true
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ -h|--help)
+ print_help
+ exit 0
+ ;;
+ --)
+ shift
+ POSITIONAL+=("$@")
+ break
+ ;;
+ -*)
+ log_error "未知选项: $1"
+ exit 2
+ ;;
+ *)
+ POSITIONAL+=("$1")
+ shift
+ ;;
+ esac
+ done
+
+ case "$ICON_MODE" in
+ auto|required|skip|cache-only) ;;
+ *)
+ log_error "无效图标模式: $ICON_MODE"
+ exit 2
+ ;;
+ esac
+
+ if [[ ${#POSITIONAL[@]} -gt 1 ]]; then
+ OUTPUT_BASE="${POSITIONAL[1]}"
+ fi
+}
+
# 解析镜像引用(支持 registry/namespace/repo:tag)
parse_image_ref() {
local image_ref="$1"
@@ -110,10 +200,10 @@ get_latest_tag_docker_hub() {
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)
+ tags=$(curl -fsSL --connect-timeout 5 --max-time 20 "$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)
+ 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
@@ -130,16 +220,16 @@ get_latest_tag_ghcr() {
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)
+ token=$(curl -fsSL --connect-timeout 5 --max-time 20 "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)
+ tags=$(curl -fsSL --connect-timeout 5 --max-time 20 -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)
+ 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
@@ -278,11 +368,11 @@ extract_from_github() {
# 获取项目信息
local repo_info
- repo_info=$(curl -s "$api_url" 2>/dev/null || echo "{}")
+ repo_info=$(curl -fsSL --connect-timeout 5 --max-time 20 "$api_url" 2>/dev/null || echo "{}")
# 提取字段
APP_NAME=$(echo "$repo_info" | jq -r '.name // empty')
- APP_KEY=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]')
+ APP_KEY=$(normalize_app_key "$APP_NAME")
DESCRIPTION=$(echo "$repo_info" | jq -r '.description // empty')
GITHUB="$github_url"
WEBSITE=$(echo "$repo_info" | jq -r '.homepage // empty')
@@ -306,11 +396,11 @@ extract_from_compose() {
log_info "正在解析 docker-compose 内容"
# 提取服务名
- SERVICE_NAME=$(echo "$compose_content" | yq '.services | keys | .[0]' 2>/dev/null || echo "")
+ SERVICE_NAME=$(echo "$compose_content" | yq -r '.services | keys | .[0]' 2>/dev/null || echo "")
# 提取镜像
local image
- image=$(echo "$compose_content" | yq ".services.${SERVICE_NAME}.image" 2>/dev/null || echo "")
+ image=$(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].image" 2>/dev/null || echo "")
parse_image_ref "$image"
# 提取端口
@@ -318,12 +408,24 @@ extract_from_compose() {
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)
+ done < <(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].ports[]" 2>/dev/null || true)
if [[ ${#PORT_ENTRIES[@]} -eq 0 ]]; then
PORT_ENTRIES=("8080")
fi
+ SERVICE_VOLUMES=()
+ while IFS= read -r line; do
+ [[ -z "$line" || "$line" == "null" ]] && continue
+ SERVICE_VOLUMES+=("$line")
+ done < <(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].volumes[]?" 2>/dev/null || true)
+
+ SERVICE_ENV=()
+ while IFS= read -r line; do
+ [[ -z "$line" || "$line" == "null" ]] && continue
+ SERVICE_ENV+=("$line")
+ done < <(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].environment[]?" 2>/dev/null || true)
+
log_info "服务名: $SERVICE_NAME"
log_info "镜像: $IMAGE_BASE:$TAG"
log_info "端口数量: ${#PORT_ENTRIES[@]}"
@@ -341,7 +443,7 @@ extract_from_docker_run() {
parse_image_ref "$image_ref"
# 提取容器名
- SERVICE_NAME=$(echo "$cmd" | grep -oE '\-\-name[= ]+[^ ]+' | awk '{print $NF}' || echo "app")
+ SERVICE_NAME=$(echo "$cmd" | grep -oE '\-\-name(=| )[A-Za-z0-9_.-]+' | sed -E 's/^--name[= ]//' || echo "app")
# 提取端口
PORT_ENTRIES=()
@@ -354,8 +456,20 @@ extract_from_docker_run() {
PORT_ENTRIES=("8080")
fi
+ SERVICE_VOLUMES=()
+ while IFS= read -r line; do
+ [[ -z "$line" ]] && continue
+ SERVICE_VOLUMES+=("$line")
+ done < <(echo "$cmd" | grep -oE '(-v|--volume)(=| )[^ ]+' | sed -E 's/^(-v|--volume)[= ]//' || true)
+
+ SERVICE_ENV=()
+ while IFS= read -r line; do
+ [[ -z "$line" ]] && continue
+ SERVICE_ENV+=("$line")
+ done < <(echo "$cmd" | grep -oE '(-e|--env)(=| )[^ ]+' | sed -E 's/^(-e|--env)[= ]//' || true)
+
APP_NAME="$SERVICE_NAME"
- APP_KEY=$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
+ APP_KEY=$(normalize_app_key "$SERVICE_NAME")
log_info "应用名称: $APP_NAME"
log_info "镜像: $IMAGE_BASE:$TAG"
@@ -466,11 +580,41 @@ EOF
cat >> "$output_file" << EOF
volumes:
+EOF
+
+ if ((${#SERVICE_VOLUMES[@]} > 0)); then
+ local volume
+ for volume in "${SERVICE_VOLUMES[@]}"; do
+ cat >> "$output_file" << EOF
+ - ${volume}
+EOF
+ done
+ else
+ cat >> "$output_file" << EOF
- ./data/data:/app/data
+EOF
+ fi
+
+ cat >> "$output_file" << EOF
environment:
+EOF
+
+ if ((${#SERVICE_ENV[@]} > 0)); then
+ local env
+ for env in "${SERVICE_ENV[@]}"; do
+ cat >> "$output_file" << EOF
+ - ${env}
+EOF
+ done
+ else
+ cat >> "$output_file" << EOF
- PUID=0
- PGID=0
- UMASK=022
+EOF
+ fi
+
+ cat >> "$output_file" << EOF
image: ${IMAGE_BASE:-app}:${image_tag}
labels:
createdBy: "Apps"
@@ -531,58 +675,30 @@ EOF
download_icon() {
local app_name="$1"
local output_file="$2"
- local github_url="${3:-}"
- local icon_name
+ local args=(--mode "$ICON_MODE" --cache-dir "$ICON_CACHE_DIR")
- icon_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]')
-
- 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
+ if [[ -n "$ICON_URL" ]]; then
+ args+=(--url "$ICON_URL")
fi
- # 尝试各个图标源
- for source in "${ICON_SOURCES[@]}"; do
- local icon_url="${source}/${icon_name}.png"
- if curl -sSL -o "$output_file" "$icon_url" 2>/dev/null && [[ -s "$output_file" ]]; then
- log_info "图标下载成功: $icon_url"
- return 0
- fi
- done
+ if bash "${SCRIPT_DIR}/download-icon.sh" "${args[@]}" "$app_name" "$output_file"; then
+ return 0
+ fi
+
+ if [[ "$ICON_MODE" == "required" ]]; then
+ log_error "图标为 required 模式,但未能获取图标"
+ return 1
+ fi
log_warn "未找到图标,请手动补充 logo.png"
- return 1
+ return 0
}
# 主函数
main() {
- local input="$1"
+ parse_args "$@"
+
+ local input="${POSITIONAL[0]:-}"
local input_type
# 参数检查
@@ -607,16 +723,16 @@ main() {
fi
;;
compose_url)
- COMPOSE_CONTENT=$(curl -s "$input" 2>/dev/null)
+ COMPOSE_CONTENT=$(curl -fsSL --connect-timeout 5 --max-time 20 "$input" 2>/dev/null)
extract_from_compose "$COMPOSE_CONTENT"
APP_NAME="$SERVICE_NAME"
- APP_KEY=$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
+ APP_KEY=$(normalize_app_key "$SERVICE_NAME")
;;
compose_file)
COMPOSE_CONTENT=$(cat "$input")
extract_from_compose "$COMPOSE_CONTENT"
APP_NAME="$SERVICE_NAME"
- APP_KEY=$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
+ APP_KEY=$(normalize_app_key "$SERVICE_NAME")
;;
docker_run)
extract_from_docker_run "$input"
@@ -627,6 +743,16 @@ main() {
;;
esac
+ if [[ -n "$USER_APP_NAME" ]]; then
+ APP_NAME="$USER_APP_NAME"
+ fi
+ if [[ -n "$USER_APP_KEY" ]]; then
+ APP_KEY=$(normalize_app_key "$USER_APP_KEY")
+ fi
+ if [[ -z "${APP_KEY:-}" ]]; then
+ APP_KEY=$(normalize_app_key "${APP_NAME:-app}")
+ fi
+
# 生成端口映射数组
HOST_PORTS=()
CONTAINER_PORTS=()
@@ -651,16 +777,33 @@ main() {
done
# 获取最新版本号
- VERSION_TAG=$(get_latest_tag)
- if [[ -z "$VERSION_TAG" ]]; then
- VERSION_TAG="$TAG"
+ if [[ -n "$USER_VERSION" ]]; then
+ VERSION_TAG="$USER_VERSION"
+ else
+ VERSION_TAG=$(get_latest_tag)
+ if [[ -z "$VERSION_TAG" ]]; then
+ VERSION_TAG="$TAG"
+ fi
fi
if [[ -z "$VERSION_TAG" ]]; then
VERSION_TAG="latest"
fi
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo "app_key=${APP_KEY}"
+ echo "app_name=${APP_NAME:-}"
+ echo "service=${SERVICE_NAME:-}"
+ echo "image=${IMAGE_BASE:-}:${VERSION_TAG}"
+ echo "output=${OUTPUT_BASE}/${APP_KEY}"
+ exit 0
+ fi
+
# 创建输出目录(latest + 具体版本)
local output_dir_latest="${OUTPUT_BASE}/${APP_KEY}/latest"
+ if [[ -e "${OUTPUT_BASE}/${APP_KEY}" && "$FORCE" != "true" ]]; then
+ log_error "输出目录已存在: ${OUTPUT_BASE}/${APP_KEY}(如需覆盖请使用 --force)"
+ exit 1
+ fi
mkdir -p "$output_dir_latest"
local output_dir_version=""
@@ -687,7 +830,7 @@ main() {
fi
generate_readme "${OUTPUT_BASE}/${APP_KEY}"
- download_icon "$APP_NAME" "${OUTPUT_BASE}/${APP_KEY}/logo.png" "${GITHUB:-}"
+ download_icon "$APP_NAME" "${OUTPUT_BASE}/${APP_KEY}/logo.png"
# 生成上级目录的 data.yml
generate_data_yml "${OUTPUT_BASE}/${APP_KEY}/data.yml"
diff --git a/skills/scripts/validate-app.sh b/skills/scripts/validate-app.sh
old mode 100644
new mode 100755
index 05995cb..a4700ea
--- a/skills/scripts/validate-app.sh
+++ b/skills/scripts/validate-app.sh
@@ -40,8 +40,20 @@ EOF
# 日志函数
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
-log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; ((WARNINGS++)); }
-log_error() { echo -e "${RED}[ERROR]${NC} $1"; ((ERRORS++)); }
+log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; ((WARNINGS+=1)); }
+log_error() { echo -e "${RED}[ERROR]${NC} $1"; ((ERRORS+=1)); }
+
+has_yq() {
+ command -v yq >/dev/null 2>&1
+}
+
+yq_read() {
+ local file="$1"
+ local expr="$2"
+ if has_yq; then
+ yq -r "$expr" "$file" 2>/dev/null || true
+ fi
+}
# 检查目录结构
check_directory_structure() {
@@ -66,11 +78,11 @@ check_directory_structure() {
# 查找版本目录
local version_dirs
- version_dirs=$(find "$app_dir" -maxdepth 1 -type d -name "v*" -o -name "latest" -o -name "[0-9]*" 2>/dev/null || echo "")
+ version_dirs=$(find "$app_dir" -mindepth 1 -maxdepth 1 -type d \( -name "v*" -o -name "latest" -o -name "[0-9]*" \) 2>/dev/null || echo "")
if [[ -z "$version_dirs" ]]; then
log_error "未找到版本目录(如 1.0.0/, v1.0.0/, latest/)"
- return 1
+ return 0
fi
# 检查每个版本目录
@@ -90,6 +102,9 @@ check_directory_structure() {
# 验证顶层 data.yml
validate_top_data_yml() {
local data_file="$1"
+ local app_dir app_name
+ app_dir="$(dirname "$data_file")"
+ app_name="$(basename "$app_dir")"
log_info "验证顶层 data.yml"
@@ -115,6 +130,14 @@ validate_top_data_yml() {
log_error "data.yml 缺少 additionalProperties.key"
fi
+ if has_yq; then
+ local key
+ key=$(yq_read "$data_file" '.additionalProperties.key // ""')
+ if [[ -n "$key" && "$key" != "$app_name" ]]; then
+ log_error "additionalProperties.key 必须与应用目录名一致: key=$key dir=$app_name"
+ fi
+ fi
+
# 检查 tags
if ! grep -q "tags:" "$data_file"; then
log_warn "data.yml 缺少 tags(推荐)"
@@ -153,6 +176,86 @@ validate_version_data_yml() {
fi
}
+extract_form_env_keys() {
+ local data_file="$1"
+
+ if [[ ! -f "$data_file" ]]; then
+ return 0
+ fi
+
+ if has_yq; then
+ yq -r '.additionalProperties.formFields[]?.envKey // empty' "$data_file" 2>/dev/null || true
+ else
+ grep -oE 'envKey:[[:space:]]*PANEL_APP_PORT_[A-Z0-9_]+' "$data_file" | awk '{print $2}' || true
+ fi
+}
+
+extract_compose_port_vars() {
+ local compose_file="$1"
+ grep -oE '\$\{PANEL_APP_PORT_[A-Z0-9_]+\}' "$compose_file" | sed -E 's/^\$\{//; s/\}$//' | sort -u || true
+}
+
+validate_port_variables() {
+ local data_file="$1"
+ local compose_file="$2"
+ local defined used var
+
+ defined="$(extract_form_env_keys "$data_file" | sort -u)"
+ used="$(extract_compose_port_vars "$compose_file")"
+
+ while IFS= read -r var; do
+ [[ -z "$var" ]] && continue
+ if ! grep -qx "$var" <<< "$defined"; then
+ log_error "端口变量 ${var} 在 docker-compose.yml 中使用,但未在版本 data.yml 中定义"
+ fi
+ done <<< "$used"
+}
+
+validate_image_tags() {
+ local version_dir="$1"
+ local compose_file="$2"
+ local version image tag matched_version=false
+
+ version="$(basename "$version_dir")"
+ while IFS= read -r image; do
+ [[ -z "$image" || "$image" == "null" ]] && continue
+
+ if [[ "$image" =~ @sha256: ]]; then
+ continue
+ fi
+
+ if [[ "$image" =~ :([^/:]+)$ ]]; then
+ tag="${BASH_REMATCH[1]}"
+ else
+ tag="latest"
+ fi
+
+ if [[ "$version" == "latest" && "$tag" != "latest" ]]; then
+ log_error "latest 目录中的镜像必须使用 latest tag: $image"
+ fi
+
+ if [[ "$version" != "latest" && "$tag" == "$version" ]]; then
+ matched_version=true
+ fi
+ done < <(grep -E '^[[:space:]]*image:[[:space:]]*' "$compose_file" | sed -E 's/^[[:space:]]*image:[[:space:]]*"?([^"]*)"?/\1/' || true)
+
+ if [[ "$version" != "latest" && "$matched_version" != "true" ]]; then
+ log_warn "版本目录 ${version} 未检测到与目录名一致的镜像 tag,请确认是否为多服务或特殊版本"
+ fi
+}
+
+validate_logo_file() {
+ local logo_file="$1"
+
+ if [[ ! -f "$logo_file" ]]; then
+ return 0
+ fi
+
+ if head -c 512 "$logo_file" | grep -qiE '|AccessDenied'; then
+ log_error "logo.png 看起来不是有效图片,可能是下载失败返回的 HTML/XML"
+ fi
+}
+
# 验证 docker-compose.yml
validate_docker_compose() {
local compose_file="$1"
@@ -226,14 +329,17 @@ main() {
# 执行检查
check_directory_structure "$app_dir"
validate_top_data_yml "$app_dir/data.yml"
+ validate_logo_file "$app_dir/logo.png"
# 查找并验证版本目录
local version_dirs
- version_dirs=$(find "$app_dir" -maxdepth 1 -type d \( -name "v*" -o -name "latest" -o -name "[0-9]*" \) 2>/dev/null || echo "")
+ version_dirs=$(find "$app_dir" -mindepth 1 -maxdepth 1 -type d \( -name "v*" -o -name "latest" -o -name "[0-9]*" \) 2>/dev/null || echo "")
for version_dir in $version_dirs; do
validate_version_data_yml "$version_dir/data.yml"
validate_docker_compose "$version_dir/docker-compose.yml"
+ validate_port_variables "$version_dir/data.yml" "$version_dir/docker-compose.yml"
+ validate_image_tags "$version_dir" "$version_dir/docker-compose.yml"
done
# 输出结果
diff --git a/skills/tests/run_all.sh b/skills/tests/run_all.sh
new file mode 100755
index 0000000..4fc8e6c
--- /dev/null
+++ b/skills/tests/run_all.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+cd "$ROOT_DIR"
+
+bash -n skills/scripts/download-icon.sh
+bash -n skills/scripts/generate-app.sh
+bash -n skills/scripts/validate-app.sh
+bash -n skills/tests/test_skills_scripts.sh
+
+bash skills/tests/test_skills_scripts.sh
+
+echo "All skills checks passed."
diff --git a/skills/tests/test_skills_scripts.sh b/skills/tests/test_skills_scripts.sh
new file mode 100755
index 0000000..8bf77d9
--- /dev/null
+++ b/skills/tests/test_skills_scripts.sh
@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_file() {
+ [[ -f "$1" ]] || fail "expected file: $1"
+}
+
+assert_not_file() {
+ [[ ! -f "$1" ]] || fail "expected missing file: $1"
+}
+
+assert_contains() {
+ local file="$1"
+ local pattern="$2"
+ grep -qE -- "$pattern" "$file" || fail "expected pattern '$pattern' in $file"
+}
+
+run_test() {
+ local name="$1"
+ shift
+ echo "== $name =="
+ "$@"
+}
+
+test_download_icon_skip_mode_does_not_create_logo() {
+ local out="$TMP_DIR/skip/logo.png"
+
+ bash "$ROOT_DIR/skills/scripts/download-icon.sh" --mode skip demo-app "$out" >/tmp/download_icon_skip.out
+
+ assert_not_file "$out"
+ assert_contains /tmp/download_icon_skip.out "跳过图标下载"
+}
+
+test_download_icon_cache_only_uses_cached_logo() {
+ local cache_dir="$TMP_DIR/icon-cache"
+ local out="$TMP_DIR/cache/logo.png"
+ mkdir -p "$cache_dir"
+ printf 'cached-logo' > "$cache_dir/demo-app.png"
+
+ bash "$ROOT_DIR/skills/scripts/download-icon.sh" --mode cache-only --cache-dir "$cache_dir" demo-app "$out" >/tmp/download_icon_cache.out
+
+ assert_file "$out"
+ cmp "$cache_dir/demo-app.png" "$out" || fail "cached logo was not copied"
+}
+
+test_generate_app_accepts_explicit_options_and_skips_icon() {
+ local compose="$TMP_DIR/compose.yml"
+ local output="$TMP_DIR/generated"
+ cat > "$compose" <<'YAML'
+services:
+ demo:
+ image: nginx:1.25.3
+ ports:
+ - "18080:80"
+YAML
+
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --app-key demo-app \
+ --name DemoApp \
+ --version 1.25.3 \
+ --output "$output" \
+ --icon-mode skip \
+ "$compose" >/tmp/generate_app_options.out
+
+ assert_file "$output/demo-app/latest/docker-compose.yml"
+ assert_file "$output/demo-app/1.25.3/docker-compose.yml"
+ assert_contains "$output/demo-app/data.yml" "^name: DemoApp$"
+ assert_contains "$output/demo-app/latest/docker-compose.yml" "image: nginx:latest"
+ assert_contains "$output/demo-app/1.25.3/docker-compose.yml" "image: nginx:1.25.3"
+ assert_not_file "$output/demo-app/logo.png"
+}
+
+test_generate_app_preserves_compose_environment_and_volumes() {
+ local compose="$TMP_DIR/compose-preserve.yml"
+ local output="$TMP_DIR/generated-preserve"
+ cat > "$compose" <<'YAML'
+services:
+ demo:
+ image: nginx:1.25.3
+ ports:
+ - "18080:80"
+ volumes:
+ - ./data/nginx:/etc/nginx/conf.d
+ environment:
+ - TZ=Asia/Shanghai
+ - DEMO_FLAG=true
+YAML
+
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --app-key demo-preserve \
+ --name DemoPreserve \
+ --version 1.25.3 \
+ --output "$output" \
+ --icon-mode skip \
+ "$compose" >/tmp/generate_app_preserve.out
+
+ assert_contains "$output/demo-preserve/1.25.3/docker-compose.yml" "./data/nginx:/etc/nginx/conf.d"
+ assert_contains "$output/demo-preserve/1.25.3/docker-compose.yml" "TZ=Asia/Shanghai"
+ assert_contains "$output/demo-preserve/1.25.3/docker-compose.yml" "DEMO_FLAG=true"
+}
+
+test_validate_app_rejects_undefined_port_variable() {
+ local app="$TMP_DIR/bad-port/apps/demo-app"
+ mkdir -p "$app/1.0.0"
+ printf 'png' > "$app/logo.png"
+ cat > "$app/data.yml" <<'YAML'
+name: DemoApp
+tags:
+ - 实用工具
+title: Demo
+description: Demo
+additionalProperties:
+ key: demo-app
+ name: DemoApp
+ architectures:
+ - amd64
+YAML
+ cat > "$app/1.0.0/data.yml" <<'YAML'
+additionalProperties:
+ formFields:
+ - default: 8080
+ edit: true
+ envKey: PANEL_APP_PORT_HTTP
+ labelEn: Web Port
+ labelZh: Web端口
+ required: true
+ rule: paramPort
+ type: number
+YAML
+ cat > "$app/1.0.0/docker-compose.yml" <<'YAML'
+services:
+ demo:
+ container_name: ${CONTAINER_NAME}
+ restart: always
+ networks:
+ - 1panel-network
+ ports:
+ - "${PANEL_APP_PORT_ADMIN}:80"
+ image: nginx:1.0.0
+ labels:
+ createdBy: "Apps"
+networks:
+ 1panel-network:
+ external: true
+YAML
+
+ if bash "$ROOT_DIR/skills/scripts/validate-app.sh" "$app" >/tmp/validate_bad_port.out 2>&1; then
+ fail "validator should reject undefined port variable"
+ fi
+ assert_contains /tmp/validate_bad_port.out "未在版本 data.yml 中定义"
+}
+
+test_validate_app_rejects_latest_wrong_tag() {
+ local app="$TMP_DIR/bad-latest/apps/demo-app"
+ mkdir -p "$app/latest"
+ printf 'png' > "$app/logo.png"
+ cat > "$app/data.yml" <<'YAML'
+name: DemoApp
+tags:
+ - 实用工具
+title: Demo
+description: Demo
+additionalProperties:
+ key: demo-app
+ name: DemoApp
+ architectures:
+ - amd64
+YAML
+ cat > "$app/latest/data.yml" <<'YAML'
+additionalProperties:
+ formFields: []
+YAML
+ cat > "$app/latest/docker-compose.yml" <<'YAML'
+services:
+ demo:
+ container_name: ${CONTAINER_NAME}
+ restart: always
+ networks:
+ - 1panel-network
+ image: nginx:1.25.3
+ labels:
+ createdBy: "Apps"
+networks:
+ 1panel-network:
+ external: true
+YAML
+
+ if bash "$ROOT_DIR/skills/scripts/validate-app.sh" "$app" >/tmp/validate_bad_latest.out 2>&1; then
+ fail "validator should reject latest directory with concrete tag"
+ fi
+ assert_contains /tmp/validate_bad_latest.out "latest 目录"
+}
+
+run_test "download icon skip mode" test_download_icon_skip_mode_does_not_create_logo
+run_test "download icon cache-only mode" test_download_icon_cache_only_uses_cached_logo
+run_test "generate app explicit options" test_generate_app_accepts_explicit_options_and_skips_icon
+run_test "generate app preserves compose fields" test_generate_app_preserves_compose_environment_and_volumes
+run_test "validate undefined port variable" test_validate_app_rejects_undefined_port_variable
+run_test "validate latest image tag" test_validate_app_rejects_latest_wrong_tag
+
+echo "All skills script tests passed."