diff --git a/skills/README.md b/skills/README.md
index 23a47a8..47f44ca 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -10,6 +10,12 @@ cd /root/github/1Panel-Appstore/skills
# 从 compose 生成草稿,跳过图标下载
./scripts/generate-app.sh --app-key my-app --name MyApp --version 1.2.3 --icon-mode skip ./docker-compose.yml
+# 多服务 compose 指定主服务
+./scripts/generate-app.sh --service web --app-key my-app --name MyApp --version 1.2.3 --icon-mode skip ./docker-compose.yml
+
+# 检查依赖
+./scripts/generate-app.sh --check-deps
+
# 下载或复用图标
./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
@@ -25,17 +31,21 @@ cd /root/github/1Panel-Appstore/skills
`scripts/generate-app.sh` 支持 GitHub URL、compose URL、本地 compose 文件和 `docker run` 命令。
+GitHub URL 会按仓库默认分支、`main`、`master` 顺序尝试常见 compose 文件路径,例如 `docker-compose.yml`、`compose.yaml`、`deploy/docker-compose.yml`。如果没有找到 compose,会从 README 中尝试提取单行 `docker run` 命令作为草稿来源。
+
常用参数:
```text
--output
输出目录,默认 ./apps
--app-key 指定应用目录名
--name 指定应用显示名
+--service 指定多服务 compose 的主服务
--version 指定具体版本目录和镜像 tag
--icon-mode auto|required|skip|cache-only
--icon-url 使用指定图标 URL
--force 允许覆盖已有生成目录
--dry-run 只解析并输出结果,不写文件
+--check-deps 只检查依赖工具
```
## 图标策略
diff --git a/skills/SKILL.md b/skills/SKILL.md
index 946a2e2..5056227 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -37,9 +37,9 @@ Some apps also include `latest/`. When both `latest/` and a concrete version exi
## Workflow
1. Inspect the source deployment.
- - GitHub: inspect README/deploy docs and registry image.
+ - GitHub: inspect README/deploy docs and registry image; the generator checks common compose paths before README `docker run` fallback.
- Compose: parse services, image tags, ports, volumes, environment, dependencies.
- - Docker run: parse image, `--name`, `-p`, `-v`, and `-e`.
+ - Docker run: parse image, `--name`, `-p/--publish`, `-v/--volume`, `-e/--env`, and `--env-file`.
2. Choose a stable app key.
- Lowercase, hyphenated, directory-safe.
- `additionalProperties.key` must match the directory name.
@@ -95,11 +95,13 @@ Useful generation options:
--output Output base directory. Default: ./apps
--app-key Override app directory key.
--name Override display name.
+--service Select the main service from a multi-service compose file.
--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.
+--check-deps Check required local tools.
```
Download an icon:
diff --git a/skills/examples/example-usage.md b/skills/examples/example-usage.md
index 94a1235..2212552 100644
--- a/skills/examples/example-usage.md
+++ b/skills/examples/example-usage.md
@@ -8,6 +8,7 @@ cd /root/github/1Panel-Appstore/skills
./scripts/generate-app.sh \
--app-key demo-app \
--name DemoApp \
+ --service web \
--version 1.25.3 \
--icon-mode skip \
--output ../apps \
@@ -45,10 +46,25 @@ cd /root/github/1Panel-Appstore/skills
脚本会解析:
- `--name` 作为服务名候选。
-- `-p` 生成 `PANEL_APP_PORT_*` 表单字段。
-- `-v` 保留到 compose 的 `volumes`。
+- `-p/--publish` 生成 `PANEL_APP_PORT_*` 表单字段。
+- `-v/--volume` 保留到 compose 的 `volumes`。
+- `-e/--env` 和 `--env-file` 保留到 compose。
- 镜像 tag 作为版本候选;显式 `--version` 优先。
+## GitHub 输入
+
+```bash
+./scripts/generate-app.sh \
+ --app-key github-demo \
+ --name GitHubDemo \
+ --service web \
+ --version 1.2.3 \
+ --icon-mode skip \
+ https://github.com/example/demo
+```
+
+脚本会尝试仓库默认分支、`main`、`master` 下的常见 compose 路径;如果没有找到 compose,会从 README 中尝试提取单行 `docker run` 命令。生成结果仍需要人工审查。
+
## 图标处理
```bash
@@ -64,6 +80,7 @@ cd /root/github/1Panel-Appstore/skills
## 验证
```bash
+./scripts/generate-app.sh --check-deps
./scripts/validate-app.sh ../apps/demo-app
./tests/run_all.sh
```
diff --git a/skills/scripts/generate-app.sh b/skills/scripts/generate-app.sh
index 5894e14..080f5f3 100755
--- a/skills/scripts/generate-app.sh
+++ b/skills/scripts/generate-app.sh
@@ -25,11 +25,17 @@ ICON_CACHE_DIR="${SCRIPT_DIR}/../.cache/icons"
USER_APP_KEY=""
USER_APP_NAME=""
USER_VERSION=""
+USER_SERVICE=""
FORCE=false
DRY_RUN=false
+CHECK_DEPS_ONLY=false
POSITIONAL=()
SERVICE_VOLUMES=()
SERVICE_ENV=()
+SERVICE_ENV_FILES=()
+DOCKER_RUN_CONTENT=""
+GITHUB_API_BASE_URL="${GITHUB_API_BASE_URL:-https://api.github.com/repos}"
+GITHUB_RAW_BASE_URL="${GITHUB_RAW_BASE_URL:-https://raw.githubusercontent.com}"
# 打印帮助
print_help() {
@@ -71,11 +77,13 @@ ${YELLOW}选项:${NC}
--output <目录> 输出目录(默认: ./apps)
--app-key 显式指定应用目录名
--name <名称> 显式指定应用显示名
+ --service <服务名> 从 compose 中选择主服务
--version <版本> 显式指定具体版本目录和镜像 tag
--icon-mode <模式> auto|required|skip|cache-only
--icon-url 使用指定图标 URL
--force 允许覆盖已有输出目录
--dry-run 只解析和打印结果,不写文件
+ --check-deps 只检查脚本依赖
EOF
}
@@ -88,6 +96,25 @@ normalize_app_key() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--' | tr -cd 'a-z0-9.-'
}
+check_dependencies() {
+ local missing=()
+ local dep
+
+ for dep in curl jq yq python3; do
+ if ! command -v "$dep" >/dev/null 2>&1; then
+ missing+=("$dep")
+ fi
+ done
+
+ if ((${#missing[@]} > 0)); then
+ log_error "缺少依赖: ${missing[*]}"
+ log_info "请先安装缺失工具后再运行 generate-app.sh"
+ return 1
+ fi
+
+ return 0
+}
+
parse_args() {
POSITIONAL=()
while [[ $# -gt 0 ]]; do
@@ -104,6 +131,10 @@ parse_args() {
USER_APP_NAME="${2:-}"
shift 2
;;
+ --service)
+ USER_SERVICE="${2:-}"
+ shift 2
+ ;;
--version)
USER_VERSION="${2:-}"
shift 2
@@ -124,6 +155,10 @@ parse_args() {
DRY_RUN=true
shift
;;
+ --check-deps)
+ CHECK_DEPS_ONLY=true
+ shift
+ ;;
-h|--help)
print_help
exit 0
@@ -358,13 +393,13 @@ detect_input_type() {
# 从 GitHub 项目提取信息
extract_from_github() {
local github_url="$1"
- local api_url temp_dir
+ local api_url
log_info "正在从 GitHub 项目提取信息: $github_url"
- # 转换为 API URL
- # https://github.com/owner/repo -> https://api.github.com/repos/owner/repo
- api_url="${github_url/github\.com/api.github.com\/repos}"
+ local owner_repo
+ owner_repo=$(echo "$github_url" | sed -E 's|https?://github.com/||; s|/$||; s|\.git$||')
+ api_url="${GITHUB_API_BASE_URL%/}/${owner_repo}"
# 获取项目信息
local repo_info
@@ -379,16 +414,96 @@ extract_from_github() {
if [[ -z "$WEBSITE" ]]; then
WEBSITE="$github_url"
fi
+ DEFAULT_BRANCH=$(echo "$repo_info" | jq -r '.default_branch // "main"')
- # 尝试查找 docker-compose.yml
- local owner_repo
- owner_repo=$(echo "$github_url" | sed -E 's|https?://github.com/||')
- COMPOSE_URL="https://raw.githubusercontent.com/${owner_repo}/main/docker-compose.yml"
+ COMPOSE_CONTENT=$(discover_github_compose "$owner_repo" "$DEFAULT_BRANCH")
+ if [[ -z "${COMPOSE_CONTENT:-}" ]]; then
+ DOCKER_RUN_CONTENT=$(discover_github_docker_run "$owner_repo" "$DEFAULT_BRANCH")
+ fi
+ if [[ -z "${COMPOSE_CONTENT:-}" && -z "${DOCKER_RUN_CONTENT:-}" ]]; then
+ log_error "未在 GitHub 项目中发现常见 docker-compose 文件或 README docker run 命令"
+ exit 1
+ fi
log_info "应用名称: $APP_NAME"
log_info "描述: $DESCRIPTION"
}
+discover_github_compose() {
+ local owner_repo="$1"
+ local default_branch="$2"
+ local branches=("$default_branch")
+ local branch path url content
+ local paths=(
+ "docker-compose.yml"
+ "docker-compose.yaml"
+ "compose.yml"
+ "compose.yaml"
+ "deploy/docker-compose.yml"
+ "deploy/docker-compose.yaml"
+ "docker/docker-compose.yml"
+ "docker/docker-compose.yaml"
+ )
+
+ if [[ "$default_branch" != "main" ]]; then
+ branches+=("main")
+ fi
+ if [[ "$default_branch" != "master" ]]; then
+ branches+=("master")
+ fi
+
+ for branch in "${branches[@]}"; do
+ for path in "${paths[@]}"; do
+ url="${GITHUB_RAW_BASE_URL%/}/${owner_repo}/${branch}/${path}"
+ content=$(curl -fsSL --connect-timeout 5 --max-time 20 "$url" 2>/dev/null || true)
+ if [[ -n "$content" ]] && echo "$content" | yq -e '.services' >/dev/null 2>&1; then
+ log_info "发现 compose 文件: ${path} (${branch})" >&2
+ printf '%s\n' "$content"
+ return 0
+ fi
+ done
+ done
+
+ return 0
+}
+
+discover_github_docker_run() {
+ local owner_repo="$1"
+ local default_branch="$2"
+ local branches=("$default_branch")
+ local branch path url content line
+ local paths=(
+ "README.md"
+ "README_en.md"
+ "docs/README.md"
+ )
+
+ if [[ "$default_branch" != "main" ]]; then
+ branches+=("main")
+ fi
+ if [[ "$default_branch" != "master" ]]; then
+ branches+=("master")
+ fi
+
+ for branch in "${branches[@]}"; do
+ for path in "${paths[@]}"; do
+ url="${GITHUB_RAW_BASE_URL%/}/${owner_repo}/${branch}/${path}"
+ content=$(curl -fsSL --connect-timeout 5 --max-time 20 "$url" 2>/dev/null || true)
+ if [[ -z "$content" ]]; then
+ continue
+ fi
+ line=$(printf '%s\n' "$content" | sed -n 's/^[[:space:]]*//; /docker run /{s/^`*//; s/`*$//; p; q;}')
+ if [[ -n "$line" ]]; then
+ log_info "发现 README docker run 命令: ${path} (${branch})" >&2
+ printf '%s\n' "$line"
+ return 0
+ fi
+ done
+ done
+
+ return 0
+}
+
# 从 docker-compose 提取信息
extract_from_compose() {
local compose_content="$1"
@@ -396,11 +511,23 @@ extract_from_compose() {
log_info "正在解析 docker-compose 内容"
# 提取服务名
- SERVICE_NAME=$(echo "$compose_content" | yq -r '.services | keys | .[0]' 2>/dev/null || echo "")
+ if [[ -n "$USER_SERVICE" ]]; then
+ SERVICE_NAME="$USER_SERVICE"
+ if ! echo "$compose_content" | yq -e ".services[\"${SERVICE_NAME}\"]" >/dev/null 2>&1; then
+ log_error "compose 中不存在指定服务: $SERVICE_NAME"
+ exit 1
+ fi
+ else
+ SERVICE_NAME=$(echo "$compose_content" | yq -r '.services | keys | .[0]' 2>/dev/null || echo "")
+ fi
# 提取镜像
local image
image=$(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].image" 2>/dev/null || echo "")
+ if [[ -z "$image" || "$image" == "null" ]]; then
+ log_error "服务 ${SERVICE_NAME} 缺少 image,无法生成应用版本"
+ exit 1
+ fi
parse_image_ref "$image"
# 提取端口
@@ -426,6 +553,12 @@ extract_from_compose() {
SERVICE_ENV+=("$line")
done < <(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].environment[]?" 2>/dev/null || true)
+ SERVICE_ENV_FILES=()
+ while IFS= read -r line; do
+ [[ -z "$line" || "$line" == "null" ]] && continue
+ SERVICE_ENV_FILES+=("$line")
+ done < <(echo "$compose_content" | yq -r ".services[\"${SERVICE_NAME}\"].env_file[]?" 2>/dev/null || true)
+
log_info "服务名: $SERVICE_NAME"
log_info "镜像: $IMAGE_BASE:$TAG"
log_info "端口数量: ${#PORT_ENTRIES[@]}"
@@ -437,37 +570,104 @@ extract_from_docker_run() {
log_info "正在解析 docker run 命令"
- # 提取镜像
- local image_ref
- image_ref=$(echo "$cmd" | awk '{print $NF}')
- parse_image_ref "$image_ref"
-
- # 提取容器名
- SERVICE_NAME=$(echo "$cmd" | grep -oE '\-\-name(=| )[A-Za-z0-9_.-]+' | sed -E 's/^--name[= ]//' || echo "app")
-
- # 提取端口
- PORT_ENTRIES=()
+ local args=()
while IFS= read -r line; do
- [[ -z "$line" ]] && continue
- PORT_ENTRIES+=("$line")
- done < <(echo "$cmd" | grep -oE '\-p[= ]+[^ ]+' | awk '{print $NF}' || true)
+ args+=("$line")
+ done < <(python3 - "$cmd" <<'PY'
+import shlex
+import sys
+
+for item in shlex.split(sys.argv[1]):
+ print(item)
+PY
+)
+
+ local i=0
+ if [[ "${args[0]:-}" == "docker" && "${args[1]:-}" == "run" ]]; then
+ i=2
+ fi
+
+ SERVICE_NAME="app"
+ PORT_ENTRIES=()
+ SERVICE_VOLUMES=()
+ SERVICE_ENV=()
+ SERVICE_ENV_FILES=()
+
+ local image_ref=""
+ while ((i < ${#args[@]})); do
+ local arg="${args[$i]}"
+ case "$arg" in
+ --name=*)
+ SERVICE_NAME="${arg#--name=}"
+ ;;
+ --name)
+ ((i+=1))
+ SERVICE_NAME="${args[$i]:-app}"
+ ;;
+ -p|--publish)
+ ((i+=1))
+ PORT_ENTRIES+=("${args[$i]:-}")
+ ;;
+ -p=*|--publish=*)
+ PORT_ENTRIES+=("${arg#*=}")
+ ;;
+ -v|--volume)
+ ((i+=1))
+ SERVICE_VOLUMES+=("${args[$i]:-}")
+ ;;
+ -v=*|--volume=*)
+ SERVICE_VOLUMES+=("${arg#*=}")
+ ;;
+ -e|--env)
+ ((i+=1))
+ SERVICE_ENV+=("${args[$i]:-}")
+ ;;
+ -e=*|--env=*)
+ SERVICE_ENV+=("${arg#*=}")
+ ;;
+ --env-file)
+ ((i+=1))
+ SERVICE_ENV_FILES+=("${args[$i]:-}")
+ ;;
+ --env-file=*)
+ SERVICE_ENV_FILES+=("${arg#--env-file=}")
+ ;;
+ --)
+ ((i+=1))
+ image_ref="${args[$i]:-}"
+ break
+ ;;
+ -d|--detach|--rm|--init|-i|-t|-it|-ti|--privileged)
+ ;;
+ --restart|--network|--hostname|--user|--workdir|--entrypoint|--add-host|--dns)
+ ((i+=1))
+ ;;
+ --restart=*|--network=*|--hostname=*|--user=*|--workdir=*|--entrypoint=*|--add-host=*|--dns=*)
+ ;;
+ -*)
+ if [[ "$arg" != *=* && "${args[$((i + 1))]:-}" != -* && "${args[$((i + 1))]:-}" != "" ]]; then
+ ((i+=1))
+ fi
+ ;;
+ *)
+ image_ref="$arg"
+ break
+ ;;
+ esac
+ ((i+=1))
+ done
+
+ if [[ -z "$image_ref" ]]; then
+ log_error "未能从 docker run 命令中解析出镜像"
+ exit 1
+ fi
+
+ parse_image_ref "$image_ref"
if [[ ${#PORT_ENTRIES[@]} -eq 0 ]]; then
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=$(normalize_app_key "$SERVICE_NAME")
@@ -614,6 +814,18 @@ EOF
EOF
fi
+ if ((${#SERVICE_ENV_FILES[@]} > 0)); then
+ cat >> "$output_file" << EOF
+ env_file:
+EOF
+ local env_file
+ for env_file in "${SERVICE_ENV_FILES[@]}"; do
+ cat >> "$output_file" << EOF
+ - ${env_file}
+EOF
+ done
+ fi
+
cat >> "$output_file" << EOF
image: ${IMAGE_BASE:-app}:${image_tag}
labels:
@@ -698,6 +910,12 @@ download_icon() {
main() {
parse_args "$@"
+ if [[ "$CHECK_DEPS_ONLY" == "true" ]]; then
+ check_dependencies
+ exit $?
+ fi
+ check_dependencies
+
local input="${POSITIONAL[0]:-}"
local input_type
@@ -717,9 +935,10 @@ main() {
case "$input_type" in
github)
extract_from_github "$input"
- COMPOSE_CONTENT=$(curl -s "$COMPOSE_URL" 2>/dev/null || echo "")
if [[ -n "$COMPOSE_CONTENT" ]]; then
extract_from_compose "$COMPOSE_CONTENT"
+ elif [[ -n "$DOCKER_RUN_CONTENT" ]]; then
+ extract_from_docker_run "$DOCKER_RUN_CONTENT"
fi
;;
compose_url)
diff --git a/skills/tests/test_skills_scripts.sh b/skills/tests/test_skills_scripts.sh
index 8bf77d9..ea0898c 100755
--- a/skills/tests/test_skills_scripts.sh
+++ b/skills/tests/test_skills_scripts.sh
@@ -25,6 +25,19 @@ assert_contains() {
grep -qE -- "$pattern" "$file" || fail "expected pattern '$pattern' in $file"
}
+make_fake_path_without() {
+ local dir="$1"
+ shift
+ mkdir -p "$dir"
+
+ local tool
+ for tool in awk basename cat chmod cmp cp dirname env find grep head mkdir mktemp mv printf rm sed sort tail tr; do
+ if command -v "$tool" >/dev/null 2>&1; then
+ ln -s "$(command -v "$tool")" "$dir/$tool"
+ fi
+ done
+}
+
run_test() {
local name="$1"
shift
@@ -32,6 +45,19 @@ run_test() {
"$@"
}
+test_generate_app_reports_missing_dependencies() {
+ local fake_path="$TMP_DIR/fake-path"
+ make_fake_path_without "$fake_path"
+
+ if PATH="$fake_path" /usr/bin/bash "$ROOT_DIR/skills/scripts/generate-app.sh" --check-deps >/tmp/generate_missing_deps.out 2>&1; then
+ fail "dependency check should fail when yq/jq/curl are missing"
+ fi
+ assert_contains /tmp/generate_missing_deps.out "缺少依赖"
+ assert_contains /tmp/generate_missing_deps.out "yq"
+ assert_contains /tmp/generate_missing_deps.out "jq"
+ assert_contains /tmp/generate_missing_deps.out "curl"
+}
+
test_download_icon_skip_mode_does_not_create_logo() {
local out="$TMP_DIR/skip/logo.png"
@@ -109,6 +135,125 @@ YAML
assert_contains "$output/demo-preserve/1.25.3/docker-compose.yml" "DEMO_FLAG=true"
}
+test_generate_app_uses_explicit_compose_service() {
+ local compose="$TMP_DIR/compose-service.yml"
+ local output="$TMP_DIR/generated-service"
+ cat > "$compose" <<'YAML'
+services:
+ db:
+ image: postgres:16
+ ports:
+ - "15432:5432"
+ web:
+ image: ghcr.io/example/webapp:2.3.4
+ ports:
+ - "18080:8080"
+ environment:
+ - WEB_MODE=prod
+YAML
+
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --service web \
+ --app-key demo-service \
+ --name DemoService \
+ --version 2.3.4 \
+ --output "$output" \
+ --icon-mode skip \
+ "$compose" >/tmp/generate_app_service.out
+
+ assert_contains "$output/demo-service/2.3.4/docker-compose.yml" "web:"
+ assert_contains "$output/demo-service/2.3.4/docker-compose.yml" "image: ghcr.io/example/webapp:2.3.4"
+ assert_contains "$output/demo-service/2.3.4/docker-compose.yml" "8080"
+ assert_contains "$output/demo-service/2.3.4/data.yml" "default: 18080"
+ assert_contains "$output/demo-service/2.3.4/docker-compose.yml" "WEB_MODE=prod"
+}
+
+test_generate_app_discovers_github_default_branch_compose() {
+ local fixture="$TMP_DIR/github-fixture"
+ local output="$TMP_DIR/generated-github"
+ mkdir -p "$fixture/api/repos/example" "$fixture/raw/example/demo/master"
+
+ cat > "$fixture/api/repos/example/demo" <<'JSON'
+{
+ "name": "demo",
+ "description": "Demo GitHub app",
+ "homepage": "https://demo.example",
+ "default_branch": "master"
+}
+JSON
+ cat > "$fixture/raw/example/demo/master/compose.yaml" <<'YAML'
+services:
+ demo:
+ image: nginx:1.25.3
+ ports:
+ - "18080:80"
+YAML
+
+ GITHUB_API_BASE_URL="file://${fixture}/api/repos" \
+ GITHUB_RAW_BASE_URL="file://${fixture}/raw" \
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --app-key github-demo \
+ --version 1.25.3 \
+ --output "$output" \
+ --icon-mode skip \
+ "https://github.com/example/demo" >/tmp/generate_app_github.out
+
+ assert_contains "$output/github-demo/data.yml" "Demo GitHub app"
+ assert_contains "$output/github-demo/1.25.3/docker-compose.yml" "image: nginx:1.25.3"
+}
+
+test_generate_app_discovers_github_readme_docker_run() {
+ local fixture="$TMP_DIR/github-readme-fixture"
+ local output="$TMP_DIR/generated-github-readme"
+ mkdir -p "$fixture/api/repos/example" "$fixture/raw/example/readme-demo/main"
+
+ cat > "$fixture/api/repos/example/readme-demo" <<'JSON'
+{
+ "name": "readme-demo",
+ "description": "README docker run app",
+ "homepage": "",
+ "default_branch": "main"
+}
+JSON
+ cat > "$fixture/raw/example/readme-demo/main/README.md" <<'MD'
+# README Demo
+
+```bash
+docker run -d --name readme-demo -p 18080:80 -e "APP_TITLE=Readme Demo" nginx:1.25.3
+```
+MD
+
+ GITHUB_API_BASE_URL="file://${fixture}/api/repos" \
+ GITHUB_RAW_BASE_URL="file://${fixture}/raw" \
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --app-key github-readme-demo \
+ --version 1.25.3 \
+ --output "$output" \
+ --icon-mode skip \
+ "https://github.com/example/readme-demo" >/tmp/generate_app_github_readme.out
+
+ assert_contains "$output/github-readme-demo/1.25.3/docker-compose.yml" "image: nginx:1.25.3"
+ assert_contains "$output/github-readme-demo/1.25.3/docker-compose.yml" "APP_TITLE=Readme Demo"
+ assert_contains "$output/github-readme-demo/1.25.3/data.yml" "default: 18080"
+}
+
+test_generate_app_parses_complex_docker_run_flags() {
+ local output="$TMP_DIR/generated-run"
+
+ bash "$ROOT_DIR/skills/scripts/generate-app.sh" \
+ --app-key docker-run-demo \
+ --name DockerRunDemo \
+ --version 1.2.3 \
+ --output "$output" \
+ --icon-mode skip \
+ 'docker run -d --name docker-run-demo --env "APP_TITLE=My Demo" --env-file ./data/app.env --publish=127.0.0.1:18080:8080/tcp --volume=./data/demo:/data ghcr.io/example/demo:1.2.3' >/tmp/generate_app_run.out
+
+ assert_contains "$output/docker-run-demo/1.2.3/docker-compose.yml" "image: ghcr.io/example/demo:1.2.3"
+ assert_contains "$output/docker-run-demo/1.2.3/docker-compose.yml" "APP_TITLE=My Demo"
+ assert_contains "$output/docker-run-demo/1.2.3/docker-compose.yml" "./data/demo:/data"
+ assert_contains "$output/docker-run-demo/1.2.3/docker-compose.yml" "8080"
+}
+
test_validate_app_rejects_undefined_port_variable() {
local app="$TMP_DIR/bad-port/apps/demo-app"
mkdir -p "$app/1.0.0"
@@ -203,8 +348,13 @@ YAML
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 dependency check" test_generate_app_reports_missing_dependencies
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 "generate app explicit compose service" test_generate_app_uses_explicit_compose_service
+run_test "generate app github compose discovery" test_generate_app_discovers_github_default_branch_compose
+run_test "generate app github README docker run discovery" test_generate_app_discovers_github_readme_docker_run
+run_test "generate app complex docker run flags" test_generate_app_parses_complex_docker_run_flags
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