From 4092625c30de278c8038105f14c17436da441ecb Mon Sep 17 00:00:00 2001 From: arch3rPro Date: Tue, 26 May 2026 01:07:55 +0800 Subject: [PATCH] chore: optimize 1panel app builder skill --- skills/README.md | 223 +++--------- skills/SKILL.md | 527 ++++++---------------------- skills/evals/evals.json | 6 +- skills/examples/example-usage.md | 256 +++----------- skills/scripts/download-icon.sh | 194 +++++++--- skills/scripts/generate-app.sh | 281 +++++++++++---- skills/scripts/validate-app.sh | 116 +++++- skills/tests/run_all.sh | 16 + skills/tests/test_skills_scripts.sh | 211 +++++++++++ 9 files changed, 905 insertions(+), 925 deletions(-) mode change 100644 => 100755 skills/scripts/generate-app.sh mode change 100644 => 100755 skills/scripts/validate-app.sh create mode 100755 skills/tests/run_all.sh create mode 100755 skills/tests/test_skills_scripts.sh 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."