Skill: 1panel/1panel-appstore (version 1.0.0)

--- name: 1panel-appstore-skills description: Create 1Panel appstore/local-app installation packages for Dockerized applications from official repositories, Docker images, docker-compose.yml files, or a prepared app spec. metadata: openclaw: requires: bins: - python3 --- # 1Panel Appstore Skills Package Dockerized applications into the 1Panel appstore/local-app format. Use official application sources for installation details and generate the app package from an intermediate spec. ## Workflow 1. Resolve the application source: - Repository URL: use the provided repository as the primary source. - App name only: search mainstream open-source code hosts and confirm the official repository. - Docker image or Compose file: collect the missing app metadata from official docs or the user. 2. Inspect official files/docs for Docker image, Compose topology, ports, volumes, environment variables, runtime user, README content, website, docs, logo, and version. 3. Read only the references needed: - `references/source-policy.md` before resolving Docker install details from an app name or repository URL. - `references/appstore-format.md` for 1Panel package rules. - `references/appspec.md` before writing the intermediate spec. - `references/review-checklist.md` before final handoff when you need a packaging checklist. 4. Write an intermediate JSON spec, usually under `/tmp/<app-key>-1panel-appspec.json` or a task-specific output directory. 5. Run `scripts/generate_app_package.py --spec <spec.json> --output <output-dir>` to create the package. 6. Return the package path, generated version, source evidence, warnings, and local testing path. ## Source Rules - Never invent Docker installation details. Docker image, ports, volumes, environment variables, dependencies, and runtime user must come from the official repository or official docs. - If the user provides a repository URL, do not switch sources unless that repository links to official docs needed for Docker installation. - If only an app name is provided, confirm the official repository before packaging. If it cannot be confirmed, ask for the repository URL. - Do not use third-party tutorials, unrelated Docker Hub pages, mirrors, or guessed Compose snippets. - Prefer official container images. Use a third-party image only when the official project has no public image or only documents source builds, the user explicitly accepts the third-party image, and the third-party image page or source repository clearly identifies the upstream project. - Always generate Docker-based packages. If no reliable containerization path exists, stop and state what is missing. ## Package Rules - Prefer a single primary service when possible. For secondary services, use explicit names and make sure every service joins `1panel-network`. - Use `${CONTAINER_NAME}` for the primary service container name. Use `${CONTAINER_NAME}-<service>` for secondary service container names. - Use `PANEL_APP_PORT_HTTP` for the primary web port. Use `PANEL_APP_PORT_<PURPOSE>` for additional ports. - Every `${...}` variable in `docker-compose.yml` must be declared in version `data.yml`, except known 1Panel-provided values such as `${CONTAINER_NAME}` and service-derived `${PANEL_DB_PORT}`. - Put user-editable settings in version `data.yml` form fields. Use password fields for secrets. - Keep persistent data mounts relative, for example `./data:/app/data`. - Keep generated changes scoped to the app package or skill resources requested by the user. ## README and I18n - Generate root `README.md` in the concise existing appstore style: `## 产品介绍` and, when useful, `## 主要功能`. Generate `README_en.md` with `## Introduction` and `## Features` when the upstream application website, repository README, or app UI supports English. - Do not put generated-package diagnostics, source evidence, local testing instructions, or long operational notes in app README files. Keep those in the final response or intermediate spec. - Fill i18n maps for root `additionalProperties.description` and every version `data.yml` form field `label`. Use the appstore language set `en`, `es-es`, `ja`, `ms`, `pt-br`, `ru`, `ko`, `zh-Hant`, `zh`, and `tr`. - Reuse translations already present in existing apps for common labels such as Port, HTTP Port, Web UI Port, Password, API Key, Token, Model, Provider, and Base URL. Do not invent specialized translations when no reliable source or existing pattern exists; prefer a clear English fallback and call out the assumption. ## init.sh Rules - For every persistent relative volume, check whether the container runs as a non-root built-in user. Use official `Dockerfile` `USER`, official Compose `user:`, image docs, or installation docs as evidence. If the mounted directory must be writable by a non-root UID/GID, add an `init_permissions` or service `volume_permissions` entry so `scripts/init.sh` fixes ownership before startup. - `init.sh` content must be based on official application sources. Use `init_source_evidence` to record the official file or documentation that supports every generated init action. - Besides persistent directory permissions, add other `init_commands` only when official install docs require a host-side preflight step. Keep the script minimal. - Do not guess UID/GID values. If the official source does not reveal the runtime user and the app is known to fail on host-mounted volumes, stop and ask the user for the intended UID/GID or official container docs. - Let the package tree create persistent directories with `.gitkeep`; use `init.sh` to change permissions, not as the primary way to create expected directories. - If no init action is needed, do not generate `scripts/` or `scripts/init.sh`. ## Scripts - `scripts/generate_app_package.py` - Input: JSON app spec. - Output: `apps/<key>/data.yml`, `README.md`, `README_en.md` when applicable, `logo.png`, `<version>/data.yml`, `<version>/docker-compose.yml`, and data directories. - Use this instead of hand-writing package files when starting a new app package. - `scripts/validate_app_package.py` - Optional helper for basic generated-package checks. - Do not present it as a full 1Panel appstore review or runtime installation test. ## Output Contract When done, report: - App package path. - Generated version. - Source evidence used for Docker installation details. - Any warnings or assumptions. - Local test target: `/opt/1panel/resource/apps/local/<app-key>`.

path: README.md
content: # 1Panel Appstore Skills

[English](./README.md) | [简体中文](./README.zh-CN.md)

`1panel-appstore-skills` is a skill for creating 1Panel App Store app installation packages.

It is designed for applications that already support Docker-based deployment. It can generate a package that follows the 1Panel App Store app format from official repositories, official documentation, Docker images, `docker-compose.yml`, or a prepared intermediate spec.

## When to Use

Suitable for:

- Packaging a Dockerized application as a 1Panel App Store app.
- Converting Docker / Docker Compose installation methods from official repositories or official documentation into a 1Panel app package.
- Organizing an existing `docker-compose.yml` into the 1Panel App Store directory structure.
- Generating `data.yml`, `docker-compose.yml`, README files, and data directories from a prepared app spec.

Not suitable for:

- Applications without a reliable Docker-based installation method.
- Scenarios that require inventing the application deployment architecture, image, ports, data directories, or environment variables.
- Publishing to a remote app store repository. This skill only generates app package content.

## Supported Inputs

- Application name or official repository URL.
- Docker image and required deployment parameters.
- Official `docker-compose.yml` / `compose.yml` file.
- Intermediate JSON spec that follows `references/appspec.md`.

## Output

The generated result is a 1Panel App Store app package directory. The default structure is:

```text
apps/<app-key>/
  logo.png
  README.md
  README_en.md
  data.yml
  <version>/
    data.yml
    docker-compose.yml
    data/
    scripts/        # Optional: generate init.sh when persistent directory permissions need to be handled
```

Based on the input and official sources, it handles:

- Basic app information, version, type, tags, website, documentation, and repository URL.
- Main service and dependent service images, ports, environment variables, data volumes, and startup dependencies.
- `data.yml` fields, form fields, and multilingual descriptions required by 1Panel.
- Chinese README; English README when the official application or documentation supports English.
- Persistent directory permission initialization script, generated only when official sources prove it is required.

## Project Layout

```text
1panel-appstore-skills/
  README.md
  README.zh-CN.md
  SKILL.md
  assets/
  references/
  scripts/
```

Core files:

- `SKILL.md`: defines trigger scenarios, workflow, and packaging rules.
- `assets/sample-appspec.json`: intermediate spec example, useful for understanding the generation flow.
- `references/appstore-format.md`: 1Panel app package directory and field rules.
- `references/source-policy.md`: source rules for application source code, official documentation, and Docker installation methods.
- `scripts/generate_app_package.py`: generates a 1Panel app package from an intermediate spec.

## Source Requirements

Docker installation methods in the app package must come from the application’s official repository or official documentation.

If a repository URL is provided, use that repository as the primary source. Only when no repository URL is provided should mainstream open-source code hosts such as GitHub, Gitee, and GitLab be searched, and the discovered repository must be confirmed as official.

Useful sources include repository `README`, `docs`, `docker-compose.yml`, `compose.yml`, `Dockerfile`, `.env.example`, deployment documentation, and official documentation pages that explicitly provide Docker / Docker Compose installation instructions. Container runtime user, UID/GID, and data directory permission requirements must also come from official `Dockerfile`, Compose files, image documentation, or project documentation.

When official sources do not provide a Docker installation method, reliable containerized installation information must be added before generating the app package.

Prefer official container images. If no public official image is available, or if the official project only provides source-build instructions, a third-party image can be used only after the user explicitly accepts it, and the third-party image source must be recorded.

If `init.sh` is generated, its content also needs official-source evidence. The most common use is handling persistent directory permissions. If official documentation clearly requires other pre-installation actions, those commands can also be included. When no initialization action is needed, the app package does not include a `scripts/` directory.

## Usage Examples

```text
Package https://github.com/example/myapp as a 1Panel App Store app.
```

```text
Package OpenClaw as a 1Panel App Store app.
```

```text
Package ghcr.io/example/myapp:1.0.0 as a 1Panel App Store app.
Host port 8080, container port 3000.
```

```text
Use 1panel-appstore-skills to convert the docker-compose.yml in the current directory into a 1Panel App Store app package.
```

## Generate

If an intermediate spec is already prepared, run:

```bash
python3 scripts/generate_app_package.py \
  --spec assets/sample-appspec.json \
  --output apps
```

The generated package directory will be:

```text
apps/<app-key>
```

## Local Test

Place the generated app directory in:

```text
/opt/1panel/resource/apps/local/<app-key>
```

Then refresh the local app list in the 1Panel App Store, and test install, start, stop, restart, and uninstall.


path: README.zh-CN.md
content: # 1Panel Appstore Skills

[English](./README.md) | [简体中文](./README.zh-CN.md)

`1panel-appstore-skills` 是一个用于创建 1Panel 应用商店应用安装包的 Skill。

它面向已经支持 Docker 化部署的应用，可以根据官方仓库、官方文档、Docker 镜像、`docker-compose.yml`，或已经整理好的中间 spec，生成符合 1Panel 应用商店应用格式的安装包。

## 何时使用

适合在以下场景使用：

- 将一个 Docker 化应用封装为 1Panel 应用商店应用包。
- 将官方仓库或官方文档中的 Docker / Docker Compose 安装方式转换为 1Panel 应用包。
- 将现有 `docker-compose.yml` 整理为 1Panel 应用商店目录结构。
- 基于已准备好的 app spec 生成 `data.yml`、`docker-compose.yml`、README 和数据目录。

不适合在以下场景使用：

- 应用没有可靠的 Docker 化安装方式。
- 需要凭空设计应用部署架构、镜像、端口、数据目录或环境变量。
- 需要发布到远程应用商店仓库；本 Skill 只负责生成应用包内容。

## 支持的输入

- 应用名称或官方仓库地址。
- Docker 镜像地址和必要的部署参数。
- 官方 `docker-compose.yml` / `compose.yml` 文件。
- 符合 `references/appspec.md` 的中间 JSON spec。

## 输出内容

生成结果是一个 1Panel 应用商店应用包目录，默认形态为：

```text
apps/<app-key>/
  logo.png
  README.md
  README_en.md
  data.yml
  <version>/
    data.yml
    docker-compose.yml
    data/
    scripts/        # 可选：需要处理持久化目录权限时生成 init.sh
```

其中会根据输入和官方来源处理：

- 应用基础信息、版本、类型、标签、网站、文档和仓库地址。
- 主服务和依赖服务的镜像、端口、环境变量、数据卷和启动依赖。
- 1Panel 需要的 `data.yml` 字段、表单项和多语言描述。
- 中文 README；当官方应用或文档支持英文时生成英文 README。
- 持久化目录权限初始化脚本；仅在官方来源能证明需要时生成。

## 项目结构

```text
1panel-appstore-skills/
  README.md
  README.zh-CN.md
  SKILL.md
  assets/
  references/
  scripts/
```

核心文件说明：

- `SKILL.md`：定义 Skill 的触发场景、工作流程和封装规则。
- `assets/sample-appspec.json`：中间 spec 示例，可用于了解生成流程。
- `references/appstore-format.md`：1Panel 应用包目录和字段规则。
- `references/source-policy.md`：应用源码、官方文档和 Docker 安装方式的来源规则。
- `scripts/generate_app_package.py`：根据中间 spec 生成 1Panel 应用包。

## 信息来源要求

应用包中的 Docker 安装方式必须来自应用官方仓库或官方文档。

如果提供了仓库地址，优先使用该仓库作为主线来源。只有在没有仓库地址时，才去 GitHub、Gitee、GitLab 等主流开源代码仓库中查找，并确认找到的是官方仓库。

可参考仓库内的 `README`、`docs`、`docker-compose.yml`、`compose.yml`、`Dockerfile`、`.env.example`、部署文档，以及官方文档站点中明确给出的 Docker / Docker Compose 安装说明。涉及容器运行用户、UID/GID、数据目录权限要求时，也需要来自官方 `Dockerfile`、Compose 文件或镜像文档。

当官方来源没有 Docker 安装方式时，需要先补充可靠的容器化安装信息，再生成应用包。

优先使用官方容器镜像。官方没有公开可用镜像，或官方只提供源码构建方式时，可以在用户明确接受后使用第三方镜像，并记录第三方镜像来源。

如果生成 `init.sh`，脚本内容也需要有官方来源依据。当前最常见用途是处理持久化目录权限；官方文档明确需要其他安装前处理时，也可以写入对应命令。没有初始化动作时，应用包中不包含 `scripts/` 目录。

## 使用示例

```text
帮我把 https://github.com/example/myapp 封装成 1Panel 应用商店应用。
```

```text
帮我把 OpenClaw 封装成 1Panel 应用商店应用。
```

```text
帮我把 ghcr.io/example/myapp:1.0.0 封装成 1Panel 应用商店应用。
对外端口 8080，容器端口 3000。
```

```text
使用 1panel-appstore-skills，把当前目录的 docker-compose.yml 转成 1Panel 应用商店应用包。
```

## 生成命令

如果已经准备好中间 spec，可以直接运行脚本生成：

```bash
python3 scripts/generate_app_package.py \
  --spec assets/sample-appspec.json \
  --output apps
```

生成后会得到应用包目录：

```text
apps/<app-key>
```

## 本地测试

把生成的应用目录放到：

```text
/opt/1panel/resource/apps/local/<app-key>
```

然后在 1Panel 应用商店中刷新本地应用列表，测试安装、启动、停止、重启和卸载。


path: assets/sample-appspec.json
content: {
  "key": "myapp",
  "name": "MyApp",
  "title": "MyApp",
  "version": "1.0.0",
  "description": {
    "en": "Self-hosted MyApp service",
    "es-es": "Servicio MyApp autoalojado",
    "ja": "セルフホスト型 MyApp サービス",
    "ms": "Perkhidmatan MyApp hos kendiri",
    "pt-br": "Servico MyApp auto-hospedado",
    "ru": "Самостоятельно размещаемый сервис MyApp",
    "ko": "셀프 호스팅 MyApp 서비스",
    "zh-Hant": "自託管 MyApp 服務",
    "zh": "自托管 MyApp 服务",
    "tr": "Kendi barindirdiginiz MyApp servisi"
  },
  "readme": {
    "zh": {
      "introduction": "是一款自托管应用服务。",
      "features": [
        {
          "title": "容器化部署",
          "description": "支持通过 Docker 方式部署和运行。"
        }
      ]
    },
    "en": {
      "introduction": "is a self-hosted application service.",
      "features": [
        {
          "title": "Containerized Deployment",
          "description": "Supports deployment and runtime through Docker."
        }
      ]
    }
  },
  "type": "tool",
  "tags": ["Tool"],
  "website": "https://example.com",
  "github": "https://github.com/example/myapp",
  "document": "https://docs.example.com",
  "source_evidence": [
    "https://github.com/example/myapp/blob/main/docker-compose.yml",
    "https://docs.example.com/install/docker"
  ],
  "init_source_evidence": [
    "https://github.com/example/myapp/blob/main/Dockerfile"
  ],
  "architectures": ["amd64", "arm64"],
  "services": [
    {
      "name": "myapp",
      "image": "ghcr.io/example/myapp:1.0.0",
      "primary": true,
      "user": "1000:1000",
      "ports": [
        {
          "host_env": "PANEL_APP_PORT_HTTP",
          "host_default": 8080,
          "container": 3000,
          "protocol": "tcp",
          "label": "Port"
        }
      ],
      "volumes": [
        "./data:/app/data"
      ],
      "volume_permissions": [
        {
          "path": "./data",
          "owner": "1000:1000",
          "mode": "755"
        }
      ],
      "environment": {
        "APP_ENV": "production",
        "APP_SECRET": "${APP_SECRET}"
      }
    }
  ],
  "form_fields": [
    {
      "default": "myapp",
      "envKey": "APP_SECRET",
      "labelEn": "App Secret",
      "labelZh": "App Secret",
      "random": true,
      "required": true,
      "type": "password"
    }
  ]
}


path: scripts/validate_app_package.py
content: #!/usr/bin/env python3
"""Validate a generated 1Panel appstore package."""

from __future__ import annotations

import argparse
import re
from pathlib import Path


VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}")
ENVKEY_RE = re.compile(r"envKey:\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?")
BUILTIN_VARS = {"CONTAINER_NAME", "HOST_IP", "HOST_ADDRESS"}
LANG_KEYS = ["en", "es-es", "ja", "ms", "pt-br", "ru", "ko", "zh-Hant", "zh", "tr"]
RISKY_PATTERNS = {
    "privileged: true": "privileged containers require explicit review",
    "network_mode: host": "host networking should be avoided unless required",
    "pid: host": "host PID namespace should be avoided",
    "/var/run/docker.sock": "docker socket mount is high risk",
    "- /:/": "root filesystem mount is high risk",
    "- /etc:": "host /etc mount is high risk",
}


def read(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def line_has_after(text: str, anchor: str, pattern: str, window: int = 10) -> bool:
    lines = text.splitlines()
    for idx, line in enumerate(lines):
        if anchor in line:
            return re.search(pattern, "\n".join(lines[idx : idx + window])) is not None
    return False


def block_has_languages(text: str, anchor: str, window: int = 16) -> list[str]:
    lines = text.splitlines()
    for idx, line in enumerate(lines):
        if re.match(anchor, line):
            chunk = "\n".join(lines[idx : idx + window])
            return [key for key in LANG_KEYS if not re.search(rf"^\s+{re.escape(key)}:\s+", chunk, re.M)]
    return LANG_KEYS


def label_blocks_missing_languages(text: str) -> list[str]:
    lines = text.splitlines()
    problems: list[str] = []
    for idx, line in enumerate(lines):
        if re.match(r"^\s+label:\s*$", line):
            chunk = "\n".join(lines[idx : idx + 16])
            missing = [key for key in LANG_KEYS if not re.search(rf"^\s+{re.escape(key)}:\s+", chunk, re.M)]
            if missing:
                problems.append(", ".join(missing))
    return problems


def validate(app_dir: Path) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    app_key = app_dir.name

    for rel in ("data.yml", "README.md", "logo.png"):
        if not (app_dir / rel).exists():
            errors.append(f"missing root file: {rel}")
    if not (app_dir / "README_en.md").exists():
        warnings.append("missing root file: README_en.md; generate it when the app or upstream docs support English")

    root_data = read(app_dir / "data.yml") if (app_dir / "data.yml").exists() else ""
    readme = read(app_dir / "README.md") if (app_dir / "README.md").exists() else ""
    readme_en = read(app_dir / "README_en.md") if (app_dir / "README_en.md").exists() else ""
    if readme and "## 产品介绍" not in readme:
        errors.append("README.md should follow appstore style and include ## 产品介绍")
    if readme_en and "## Introduction" not in readme_en:
        errors.append("README_en.md should follow appstore style and include ## Introduction")
    for marker in ("Source Evidence", "Local Test", "generated for 1Panel local app testing"):
        if marker in readme or marker in readme_en:
            warnings.append(f"README files should not include generated-package note: {marker}")
    if root_data:
        for token in ("additionalProperties:", "key:", "name:", "tags:", "description:", "type:", "limit:"):
            if token not in root_data:
                errors.append(f"root data.yml missing {token}")
        if not re.search(rf"key:\s*['\"]?{re.escape(app_key)}['\"]?$", root_data, re.M):
            errors.append(f"root data.yml key must match folder name {app_key!r}")
        missing_desc = block_has_languages(root_data, r"^\s+description:\s*$")
        if missing_desc:
            errors.append(f"root data.yml description missing languages: {', '.join(missing_desc)}")

    version_dirs = sorted(item for item in app_dir.iterdir() if item.is_dir() and (item / "docker-compose.yml").exists())
    if not version_dirs:
        errors.append("no version directory with docker-compose.yml found")
        return errors, warnings

    for version_dir in version_dirs:
        version = version_dir.name
        if version.startswith("v"):
            errors.append(f"version directory must not start with 'v': {version}")
        version_data_path = version_dir / "data.yml"
        compose_path = version_dir / "docker-compose.yml"
        if not version_data_path.exists():
            errors.append(f"{version}/data.yml is missing")
            continue
        version_data = read(version_data_path)
        compose = read(compose_path)

        if "additionalProperties:" not in version_data or "formFields:" not in version_data:
            errors.append(f"{version}/data.yml must contain additionalProperties.formFields")
        for missing in label_blocks_missing_languages(version_data):
            errors.append(f"{version}/data.yml label missing languages: {missing}")

        for token, message in {
            "services:": "compose must define services",
            "1panel-network": "compose must use 1panel-network",
            "external: true": "1panel-network must be external",
            "createdBy": "services must include createdBy label",
        }.items():
            if token not in compose:
                errors.append(f"{version}/docker-compose.yml: {message}")
        if "${CONTAINER_NAME}" not in compose:
            errors.append(f"{version}/docker-compose.yml: primary service should use ${{CONTAINER_NAME}}")

        declared = set(ENVKEY_RE.findall(version_data))
        variables = set(VAR_RE.findall(compose))
        allow = set(BUILTIN_VARS)
        if {"PANEL_DB_HOST", "PANEL_DB_TYPE"} & declared:
            allow.add("PANEL_DB_PORT")
        for variable in sorted(variables - allow):
            if variable not in declared:
                errors.append(f"{version}: compose variable ${{{variable}}} is not declared in data.yml")

        for env_key in sorted(declared):
            if env_key.startswith("PANEL_APP_PORT_"):
                if not line_has_after(version_data, f"envKey: {env_key}", r"rule:\s*paramPort", window=28):
                    errors.append(f"{version}: {env_key} should use rule: paramPort")
                if not line_has_after(version_data, f"envKey: {env_key}", r"type:\s*number", window=28):
                    errors.append(f"{version}: {env_key} should use type: number")

        for pattern, message in RISKY_PATTERNS.items():
            if pattern in compose:
                warnings.append(f"{version}/docker-compose.yml: {message} ({pattern})")
        for image in re.findall(r"image:\s*['\"]?([^'\"\s]+)", compose):
            if image.endswith(":latest") or ":" not in image.split("/")[-1]:
                warnings.append(f"{version}/docker-compose.yml: image is floating or untagged: {image}")

        scripts_dir = version_dir / "scripts"
        if scripts_dir.exists():
            script_files = [item for item in scripts_dir.iterdir() if item.is_file()]
            if not script_files:
                errors.append(f"{version}/scripts exists but contains no scripts")
            init_path = scripts_dir / "init.sh"
            if init_path.exists() and "Generated from official installation evidence:" not in read(init_path):
                warnings.append(f"{version}/scripts/init.sh should document official source evidence")

    return errors, warnings


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("app_dir", help="Path to apps/<app-key>")
    args = parser.parse_args()
    app_dir = Path(args.app_dir)
    if not app_dir.exists() or not app_dir.is_dir():
        print(f"error: app directory not found: {app_dir}")
        raise SystemExit(1)
    errors, warnings = validate(app_dir)
    for warning in warnings:
        print(f"warning: {warning}")
    for error in errors:
        print(f"error: {error}")
    if errors:
        raise SystemExit(1)
    print(f"OK: {app_dir}")


if __name__ == "__main__":
    main()


path: scripts/generate_app_package.py
content: #!/usr/bin/env python3
"""Generate a 1Panel appstore package from a JSON app spec."""

from __future__ import annotations

import argparse
import base64
import json
import re
import shlex
import shutil
import sys
from pathlib import Path
from typing import Any


KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$")
VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}")
SECRET_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|KEY|SALT)", re.I)
OWNER_RE = re.compile(r"^[0-9]+:[0-9]+$")
MODE_RE = re.compile(r"^[0-7]{3,4}$")
BUILTIN_VARS = {"CONTAINER_NAME", "HOST_IP", "HOST_ADDRESS", "PANEL_DB_PORT"}
LANG_KEYS = ["en", "es-es", "ja", "ms", "pt-br", "ru", "ko", "zh-Hant", "zh", "tr"]
I18N_LABELS = {
    "Port": {
        "en": "Port",
        "es-es": "Puerto",
        "ja": "ポート",
        "ms": "Port",
        "pt-br": "Porta",
        "ru": "Порт",
        "ko": "포트",
        "zh-Hant": "埠",
        "zh": "端口",
        "tr": "Bağlantı Noktası",
    },
    "Web UI Port": {
        "en": "Web UI Port",
        "es-es": "Puerto web",
        "ja": "Web UIポート",
        "ms": "Port Web UI",
        "pt-br": "Porta da Web UI",
        "ru": "Порт веб-интерфейса",
        "ko": "웹 UI 포트",
        "zh-Hant": "Web UI 埠",
        "zh": "Web UI 端口",
        "tr": "Web UI Bağlantı Noktası",
    },
    "Password": {
        "en": "Password",
        "es-es": "Contraseña",
        "ja": "パスワード",
        "ms": "Kata laluan",
        "pt-br": "Senha",
        "ru": "Пароль",
        "ko": "비밀번호",
        "zh-Hant": "密碼",
        "zh": "密码",
        "tr": "Parola",
    },
    "API Key": {
        "en": "API Key",
        "es-es": "API Clave",
        "ja": "APIキー",
        "ms": "Kunci API",
        "pt-br": "Chave da API",
        "ru": "API ключ",
        "ko": "API 키",
        "zh-Hant": "API 金鑰",
        "zh": "API Key",
        "tr": "API Anahtarı",
    },
    "Token": {
        "en": "Token",
        "es-es": "Token de acceso",
        "ja": "トークン",
        "ms": "Token",
        "pt-br": "Token",
        "ru": "Токен",
        "ko": "토큰",
        "zh-Hant": "權杖",
        "zh": "令牌",
        "tr": "Belirteç",
    },
    "Base URL": {
        "en": "Base URL",
        "es-es": "Base URL",
        "ja": "Base URL",
        "ms": "Base URL",
        "pt-br": "Base URL",
        "ru": "Base URL",
        "ko": "Base URL",
        "zh-Hant": "Base URL",
        "zh": "Base URL",
        "tr": "Base URL",
    },
}
PLACEHOLDER_PNG = base64.b64decode(
    [REDACTED_SECRET]
)


def fail(message: str) -> None:
    print(f"error: {message}", file=sys.stderr)
    raise SystemExit(1)


def warn(message: str) -> None:
    print(f"warning: {message}", file=sys.stderr)


def normalize_key(value: str) -> str:
    key = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
    key = re.sub(r"-{2,}", "-", key)
    if not KEY_RE.match(key):
        fail(f"invalid app key after normalization: {key!r}")
    if key != value:
        warn(f"normalized app key {value!r} to {key!r}")
    return key


def normalize_version(value: str) -> str:
    version = str(value).strip()
    if version.startswith("v"):
        warn(f"removed leading 'v' from version {version!r}")
        version = version[1:]
    if not version or "/" in version or version in {".", ".."}:
        fail(f"invalid version: {value!r}")
    return version


def scalar(value: Any) -> str:
    if value is None:
        return '""'
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    text = str(value)
    if text == "":
        return '""'
    safe = re.match(r"^[A-Za-z0-9_./:@%+,-]+$", text)
    special = text.lower() in {"true", "false", "null", "yes", "no", "on", "off"}
    if safe and not special and not text.startswith(("-", "@", "&", "*", "!", "{", "[", "#")):
        return text
    return json.dumps(text, ensure_ascii=False)


def dump_yaml(value: Any, indent: int = 0) -> list[str]:
    pad = " " * indent
    lines: list[str] = []
    if isinstance(value, dict):
        for key, item in value.items():
            if isinstance(item, (dict, list)):
                lines.append(f"{pad}{key}:")
                lines.extend(dump_yaml(item, indent + 2))
            else:
                lines.append(f"{pad}{key}: {scalar(item)}")
    elif isinstance(value, list):
        for item in value:
            if isinstance(item, dict):
                first = True
                for key, subitem in item.items():
                    prefix = "- " if first else "  "
                    if isinstance(subitem, (dict, list)):
                        lines.append(f"{pad}{prefix}{key}:")
                        lines.extend(dump_yaml(subitem, indent + 4))
                    else:
                        lines.append(f"{pad}{prefix}{key}: {scalar(subitem)}")
                    first = False
            elif isinstance(item, list):
                lines.append(f"{pad}-")
                lines.extend(dump_yaml(item, indent + 2))
            else:
                lines.append(f"{pad}- {scalar(item)}")
    else:
        lines.append(f"{pad}{scalar(value)}")
    return lines


def write_yaml(path: Path, value: Any) -> None:
    path.write_text("\n".join(dump_yaml(value)) + "\n", encoding="utf-8")


def normalize_lang_key(key: str) -> str:
    return "zh-Hant" if key.lower() in {"zh-hant", "zh_hant", "zh-tw"} else key


def i18n_map(value: Any, fallback_en: str, fallback_zh: str | None = None) -> dict[str, str]:
    if isinstance(value, dict):
        result = {normalize_lang_key(str(k)): str(v) for k, v in value.items() if v not in (None, "")}
    elif value not in (None, ""):
        text = str(value)
        result = {"en": text, "zh": text}
    else:
        result = {}
    result.setdefault("en", fallback_en)
    result.setdefault("zh", fallback_zh or result["en"])
    result.setdefault("zh-Hant", result["zh"])
    for key in LANG_KEYS:
        result.setdefault(key, result["en"])
    return {key: result[key] for key in LANG_KEYS}


def as_description(value: Any, fallback: str) -> tuple[str, str]:
    if isinstance(value, dict):
        return str(value.get("en") or value.get("zh") or fallback), str(value.get("zh") or value.get("en") or fallback)
    if value:
        return str(value), str(value)
    return fallback, fallback


def label_map(label: str, override: Any = None) -> dict[str, str]:
    if isinstance(override, dict):
        return i18n_map(override, str(override.get("en") or label), str(override.get("zh") or label))
    if label in I18N_LABELS:
        return dict(I18N_LABELS[label])
    return i18n_map({"en": label, "zh": label}, label, label)


def env_vars_from_value(value: Any) -> set[str]:
    if isinstance(value, str):
        return set(VAR_RE.findall(value))
    if isinstance(value, list):
        found: set[str] = set()
        for item in value:
            found.update(env_vars_from_value(item))
        return found
    if isinstance(value, dict):
        found = set()
        for item in value.values():
            found.update(env_vars_from_value(item))
        return found
    return set()


def humanize_env(env_key: str) -> str:
    return env_key.replace("PANEL_APP_PORT_", "").replace("_", " ").title()


def enrich_form_field(field: dict[str, Any]) -> dict[str, Any]:
    result = dict(field)
    label_en = str(result.get("labelEn") or result.get("labelZh") or humanize_env(str(result.get("envKey") or "")))
    label_zh = str(result.get("labelZh") or result.get("labelEn") or label_en)
    translations = label_map(label_en, result.get("label") or {"en": label_en, "zh": label_zh})
    result["labelEn"] = translations["en"]
    result["labelZh"] = translations["zh"]
    result["label"] = translations
    return result


def ensure_form_field(fields: list[dict[str, Any]], field: dict[str, Any]) -> None:
    env_key = field.get("envKey")
    if env_key and not any(existing.get("envKey") == env_key for existing in fields):
        fields.append(field)


def normalize_ports(service: dict[str, Any]) -> list[dict[str, Any]]:
    ports = service.get("ports") or []
    if ports:
        return ports
    if service.get("container_port"):
        return [
            {
                "host_env": service.get("host_port_env") or "PANEL_APP_PORT_HTTP",
                "host_default": service.get("host_port_default") or service.get("container_port"),
                "container": service["container_port"],
            }
        ]
    return []


def port_field(port: dict[str, Any]) -> dict[str, Any]:
    env_key = port.get("host_env") or "PANEL_APP_PORT_HTTP"
    label = port.get("label") or humanize_env(env_key)
    translations = label_map(label, port.get("label_i18n"))
    return {
        "default": int(port.get("host_default") or port.get("container") or 8080),
        "envKey": env_key,
        "labelEn": translations["en"],
        "labelZh": translations["zh"],
        "label": translations,
        "required": True,
        "rule": "paramPort",
        "type": "number",
    }


def env_field(env_key: str, default: Any = "") -> dict[str, Any]:
    translations = label_map(humanize_env(env_key))
    return {
        "default": default,
        "envKey": env_key,
        "labelEn": translations["en"],
        "labelZh": translations["zh"],
        "label": translations,
        "required": True,
        "type": "password" if SECRET_RE.search(env_key) else "text",
    }


def compose_port(port: dict[str, Any]) -> str:
    host_env = port.get("host_env") or "PANEL_APP_PORT_HTTP"
    container = port.get("container")
    if not container:
        fail(f"port {host_env} is missing container port")
    protocol = str(port.get("protocol") or "tcp").lower()
    suffix = "" if protocol == "tcp" else f"/{protocol}"
    return f"${{{host_env}}}:{container}{suffix}"


def service_to_compose(service: dict[str, Any], primary_name: str) -> dict[str, Any]:
    name = service.get("name")
    image = service.get("image")
    if not name or not image:
        fail("every service requires name and image")
    item: dict[str, Any] = {
        "image": image,
        "container_name": "${CONTAINER_NAME}" if name == primary_name else "${CONTAINER_NAME}-" + name,
        "restart": service.get("restart") or "always",
        "networks": ["1panel-network"],
    }
    ports = [compose_port(port) for port in normalize_ports(service)]
    if ports:
        item["ports"] = ports
    for key in ("environment", "volumes", "depends_on", "command", "entrypoint", "healthcheck", "user"):
        if key in service and service[key] not in (None, [], {}):
            item[key] = service[key]
    labels = service.get("labels") or {}
    item["labels"] = {**labels, "createdBy": "Apps"} if isinstance(labels, dict) else [*labels, "createdBy=Apps"]
    return item


def relative_mount_path(volume: str) -> Path | None:
    source = volume.split(":", 1)[0]
    if not source.startswith("./") or ".." in Path(source).parts:
        return None
    return Path(source)


def create_mount_targets(version_dir: Path, services: list[dict[str, Any]]) -> None:
    for service in services:
        for volume in service.get("volumes") or []:
            if not isinstance(volume, str):
                continue
            rel = relative_mount_path(volume)
            if rel is None:
                continue
            target = version_dir / rel
            if target.suffix:
                target.parent.mkdir(parents=True, exist_ok=True)
            else:
                target.mkdir(parents=True, exist_ok=True)
                (target / ".gitkeep").touch()


def normalize_permission_path(value: str) -> str:
    path = str(value).strip()
    if path.startswith("./"):
        path = path[2:]
    item = Path(path)
    if not path or item.is_absolute() or ".." in item.parts or item.parts in {(".",), ()}:
        fail(f"invalid init permission path: {value!r}")
    return str(item)


def collect_permission_fixes(spec: dict[str, Any], services: list[dict[str, Any]]) -> list[dict[str, str]]:
    raw_fixes: list[Any] = []
    raw_fixes.extend(spec.get("init_permissions") or [])
    for service in services:
        raw_fixes.extend(service.get("volume_permissions") or [])
    fixes: list[dict[str, str]] = []
    for raw in raw_fixes:
        path = normalize_permission_path(str(raw.get("path") or ""))
        owner = str(raw.get("owner") or "").strip()
        if not OWNER_RE.match(owner):
            fail(f"init permission for {path!r} requires numeric owner like '1000:1000'")
        fix = {"path": path, "owner": owner}
        mode = raw.get("mode")
        if mode not in (None, ""):
            mode_text = str(mode).strip()
            if not MODE_RE.match(mode_text):
                fail(f"init permission for {path!r} has invalid mode {mode!r}")
            fix["mode"] = mode_text
        if fix not in fixes:
            fixes.append(fix)
    return fixes


def write_init_script(version_dir: Path, spec: dict[str, Any], services: list[dict[str, Any]]) -> None:
    commands = [str(item).rstrip() for item in spec.get("init_commands") or []]
    fixes = collect_permission_fixes(spec, services)
    scripts_dir = version_dir / "scripts"
    if not commands and not fixes:
        if scripts_dir.exists():
            shutil.rmtree(scripts_dir)
        return
    evidence = [str(item).strip() for item in spec.get("init_source_evidence") or [] if str(item).strip()]
    if not evidence:
        fail("init.sh content requires init_source_evidence from official repository or documentation")
    scripts_dir.mkdir(exist_ok=True)
    lines = [
        "#!/bin/bash",
        "",
        "# Generated from official installation evidence:",
        *[f"# - {item}" for item in evidence],
        "",
    ]
    if commands:
        lines.extend(commands)
        lines.append("")
    for fix in fixes:
        path = shlex.quote(fix["path"])
        owner = shlex.quote(fix["owner"])
        lines.append("# Adjust host-mounted data directory permissions for the container runtime user.")
        lines.extend([f"if [ -d {path} ]; then", f"    chown -R {owner} {path}"])
        if "mode" in fix:
            lines.append(f"    chmod -R {shlex.quote(fix['mode'])} {path}")
        lines.extend(["fi", ""])
    init_path = scripts_dir / "init.sh"
    init_path.write_text("\n".join(lines), encoding="utf-8")
    init_path.chmod(0o755)


def copy_logo(spec: dict[str, Any], app_dir: Path) -> None:
    dest = app_dir / "logo.png"
    logo = spec.get("logo")
    if logo:
        src = Path(str(logo)).expanduser()
        if src.exists() and src.is_file() and src.suffix.lower() == ".png":
            shutil.copyfile(src, dest)
            return
        warn("logo is missing or not a PNG; writing placeholder logo.png")
    dest.write_bytes(PLACEHOLDER_PNG)


def build_form_fields(spec: dict[str, Any], services: list[dict[str, Any]]) -> list[dict[str, Any]]:
    fields = [enrich_form_field(field) for field in spec.get("form_fields", [])]
    defaults = spec.get("env_defaults") or {}
    for service in services:
        for port in normalize_ports(service):
            ensure_form_field(fields, port_field(port))
        referenced = set()
        for key in ("environment", "command", "entrypoint"):
            referenced.update(env_vars_from_value(service.get(key)))
        for env_key in sorted(referenced):
            if env_key in BUILTIN_VARS or env_key.startswith("PANEL_APP_PORT_"):
                continue
            ensure_form_field(fields, env_field(env_key, defaults.get(env_key, "")))
    return fields


def root_data(spec: dict[str, Any], key: str, name: str) -> dict[str, Any]:
    desc_en, desc_zh = as_description(spec.get("description"), name)
    tags = spec.get("tags") or ["Tool"]
    props: dict[str, Any] = {
        "key": key,
        "name": name,
        "tags": tags,
        "shortDescZh": spec.get("shortDescZh") or desc_zh,
        "shortDescEn": spec.get("shortDescEn") or desc_en,
        "description": i18n_map(spec.get("description"), desc_en, desc_zh),
        "type": spec.get("type") or "tool",
        "crossVersionUpdate": bool(spec.get("crossVersionUpdate", True)),
        "limit": int(spec.get("limit", 0)),
        "architectures": spec.get("architectures") or ["amd64"],
    }
    for field in ("recommend", "website", "github", "document", "memoryRequired"):
        if field in spec and spec[field] not in (None, ""):
            props[field] = spec[field]
    return {"name": name, "tags": tags, "title": spec.get("title") or name, "description": desc_zh, "additionalProperties": props}


def readme_profile(spec: dict[str, Any], lang: str, name: str) -> tuple[str, list[dict[str, str]]]:
    readme = spec.get("readme") or {}
    localized = readme.get(lang) if isinstance(readme, dict) else {}
    if isinstance(localized, dict) and localized.get("introduction"):
        features = []
        for feature in localized.get("features") or []:
            if isinstance(feature, dict) and feature.get("title") and feature.get("description"):
                features.append({"title": str(feature["title"]), "description": str(feature["description"])})
        return str(localized["introduction"]), features
    desc_en, desc_zh = as_description(spec.get("description"), name)
    return (desc_zh if lang == "zh" else desc_en), []


def readme_text(spec: dict[str, Any], name: str, lang: str) -> str:
    intro, features = readme_profile(spec, lang, name)
    if lang == "zh":
        lines = ["## 产品介绍", "", f"**{name}** {intro}", ""]
        if features:
            lines.extend(["## 主要功能", ""])
            lines.extend(f"- **{item['title']}**：{item['description']}" for item in features)
    else:
        lines = ["## Introduction", "", f"**{name}** {intro}", ""]
        if features:
            lines.extend(["## Features", ""])
            lines.extend(f"- **{item['title']}**: {item['description']}" for item in features)
    return "\n".join(lines).rstrip() + "\n"


def generate(spec: dict[str, Any], output: Path, force: bool) -> Path:
    key = normalize_key(str(spec.get("key") or spec.get("name") or ""))
    name = str(spec.get("name") or key)
    version = normalize_version(str(spec.get("version") or ""))
    services = spec.get("services") or []
    if not isinstance(services, list) or not services:
        fail("spec.services must contain at least one service")
    primary = next((svc for svc in services if svc.get("primary")), services[0])
    primary_name = primary.get("name")
    if not primary_name:
        fail("primary service requires a name")

    app_dir = output / key
    version_dir = app_dir / version
    if app_dir.exists() and not force:
        fail(f"{app_dir} already exists; pass --force to overwrite generated files")
    app_dir.mkdir(parents=True, exist_ok=True)
    version_dir.mkdir(parents=True, exist_ok=True)

    write_yaml(app_dir / "data.yml", root_data(spec, key, name))
    write_yaml(version_dir / "data.yml", {"additionalProperties": {"formFields": build_form_fields(spec, services)}})
    write_yaml(
        version_dir / "docker-compose.yml",
        {
            "services": {svc["name"]: service_to_compose(svc, primary_name) for svc in services},
            "networks": {"1panel-network": {"external": True}},
        },
    )
    create_mount_targets(version_dir, services)
    write_init_script(version_dir, spec, services)
    (app_dir / "README.md").write_text(readme_text(spec, name, "zh"), encoding="utf-8")
    if spec.get("english_supported", True):
        (app_dir / "README_en.md").write_text(readme_text(spec, name, "en"), encoding="utf-8")
    copy_logo(spec, app_dir)
    return app_dir


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--spec", required=True, help="Path to JSON app spec")
    parser.add_argument("--output", required=True, help="Output directory, usually the repo apps/ directory")
    parser.add_argument("--force", action="store_true", help="Overwrite an existing generated app directory")
    args = parser.parse_args()
    spec_path = Path(args.spec)
    if not spec_path.exists():
        fail(f"spec file not found: {spec_path}")
    app_dir = generate(json.loads(spec_path.read_text(encoding="utf-8")), Path(args.output), args.force)
    print(app_dir)


if __name__ == "__main__":
    main()


path: agents/openai.yaml
content: interface:
  display_name: "1Panel Appstore Skills"
  short_description: "Package Docker apps into 1Panel appstore format."
  default_prompt: "Package this Docker app as a 1Panel local app."


path: references/source-policy.md
content: # Source Policy

Use this before packaging an app from an application name or repository URL.

## Required Source Flow

1. If the user provides a repository URL, use that repository as the primary source of truth.
2. Do not search for alternative repositories when the provided repository contains enough official Docker installation information.
3. If the provided repository links to official documentation, use those docs as supporting evidence.
4. If the user provides only an app name, search mainstream open-source code hosts first, such as GitHub, Gitee, GitLab, Codeberg, or another well-known project-owned repository host.
5. Confirm the chosen repository is official from trustworthy signals:
   - official website linking to the repository
   - verified organization or project-owned GitHub/Gitee/GitLab namespace
   - package docs linking back to the repository
6. Fetch or inspect the source repository before generating the app package.
7. Read repository files that commonly define container installation:
   - `README.md`
   - `docs/**`
   - `docker-compose.yml`
   - `docker-compose.yaml`
   - `compose.yml`
   - `Dockerfile`
   - `.env.example`
   - deployment or installation docs
8. Use official documentation only when it is linked from the repository, project website, or project-owned docs domain.
9. For every persistent volume, check official runtime-user evidence:
   - `USER` in `Dockerfile`
   - `user:` in official Compose files
   - UID/GID notes in official image or deployment docs
   - startup errors or permission notes documented by the project
10. If a persisted host mount must be writable by a non-root container user, record the UID/GID and generate `scripts/init.sh` through `init_permissions` or `volume_permissions`.
11. If any other `init.sh` action is needed, use only project-owned repository files or official docs as evidence and record it in `init_source_evidence`.
12. Use the official repository README, official website, or official docs for `README.md` and `README_en.md` content. Keep the generated copy factual and concise.
13. Prefer official container images. Use a third-party image only when:
   - the official project has no public image or only documents source builds
   - the user explicitly accepts the third-party image
   - the third-party image page or source repository clearly identifies the upstream project
   - the spec and final response record that the image is third-party

## Allowed Evidence

The Docker installation method must be supported by at least one of:

- A Compose file in the official repository.
- A Dockerfile in the official repository plus official run instructions.
- Official documentation that explicitly describes Docker or Docker Compose installation.
- Official release/package documentation from the project owner.
- Official Dockerfile, Compose, or image docs that define the runtime user for persistent volume ownership.
- Official docs that require host-side preflight actions before container startup.
- Official README, website, or docs that describe the application and its user-facing features.
- A user-approved third-party image page or linked source repository, but only after official image absence or source-build-only status has been confirmed from official sources.

Record Docker installation evidence in `source_evidence` and summarize it in the final response.

## Not Allowed

Do not use these as authoritative sources:

- random blog posts or tutorials
- unrelated Docker Hub pages
- third-party Docker images when the official project already provides a usable public image
- third-party compose snippets
- forum answers
- mirrors that are not linked by the project
- guessed ports, volumes, env vars, or image names
- guessed UID/GID values for mounted directory ownership
- guessed init scripts or third-party shell snippets
- marketing copy, feature lists, or translations invented without source support

## Stop Conditions

Stop and explain what is missing when:

- no official source repository can be identified
- the repository and official docs have no Docker installation path
- the only available Docker instructions are third-party or unverifiable
- a third-party image is required but the user has not explicitly accepted it
- the app is not containerizable without building a custom image and no reliable build path is documented
- a persistent volume has a known permission issue, but the official source does not reveal the required UID/GID

In these cases, ask the user to provide an official Docker image, a trusted Compose file, or the intended containerization method.


path: references/review-checklist.md
content: # Review Checklist

Use this before finalizing a generated app package.

## Required Files

- Root `data.yml`
- Root `README.md`
- Root `README_en.md` when the upstream app or docs support English
- Root `logo.png`
- Version `data.yml`
- Version `docker-compose.yml`

## README

- `README.md` follows the existing appstore style: `产品介绍` and optional `主要功能`.
- `README_en.md` follows the existing appstore style: `Introduction` and optional `Features`.
- README content is based on official repository README, official website, or official docs.
- README files do not include generation evidence, local test paths, Docker internals, or long installation notes.

## Metadata

- App key matches folder name.
- Version folder does not start with `v`.
- Root `additionalProperties.key`, `name`, `tags`, `type`, `description`, and `limit` exist.
- Official website, GitHub, and docs links are included when known.
- Architecture list matches the image manifest when known.
- Root `additionalProperties.description` includes `en`, `es-es`, `ja`, `ms`, `pt-br`, `ru`, `ko`, `zh-Hant`, `zh`, and `tr`.

## Compose

- Main service uses `${CONTAINER_NAME}`.
- Services use `restart: always`.
- Services join `1panel-network`.
- `networks.1panel-network.external` is `true`.
- Services have `labels.createdBy: "Apps"`.
- Every public port uses a `PANEL_APP_PORT_*` variable.
- Every compose variable is declared in version `data.yml`, except 1Panel-provided variables.
- Persistent paths use relative mounts such as `./data`.
- Persistent relative mounts were checked against the official container runtime user.
- Third-party images are used only when official sources confirm no usable public image or source-build-only installation, the user accepted the image, and the package records the third-party source.
- Every version form field includes a full multilingual `label` map.

## Init Script

- If no init action is needed, `<version>/scripts/` does not exist.
- If a non-root container user needs write access to a persisted host directory, `<version>/scripts/init.sh` exists.
- Every `init.sh` action is backed by official source evidence recorded in the spec or final response.
- `init.sh` uses UID/GID values confirmed from official Dockerfile, Compose, or image docs.
- `init.sh` fixes only package-relative persisted paths, for example `chown -R 1000:1000 data`.
- Other `init.sh` commands are limited to official preflight requirements.
- Expected persisted directories exist in the package tree with `.gitkeep`; `init.sh` is not the only source of those directories.

## Security

- No unexpected `privileged: true`.
- No host network unless justified.
- No dangerous host mounts such as `/`, `/etc`, or `/var/run/docker.sock` unless explicitly required.
- Avoid unpinned `:latest` images unless the upstream application only publishes `latest`.
- Secrets are form fields with `type: password`.

## Local Test

- Copy the generated app folder to `/opt/1panel/resource/apps/local`.
- Refresh local apps in 1Panel.
- Install, start, stop, restart, uninstall, and reinstall.
- Confirm exposed port, data persistence, logs, and health checks.


path: references/appspec.md
content: # Intermediate App Spec

Use this JSON spec as the handoff between natural-language analysis and deterministic package generation.

## Minimal Example

```json
{
  "key": "myapp",
  "name": "MyApp",
  "title": "MyApp",
  "version": "1.0.0",
  "description": {
    "en": "Self-hosted MyApp service",
    "es-es": "Servicio MyApp autoalojado",
    "ja": "セルフホスト型 MyApp サービス",
    "ms": "Perkhidmatan MyApp hos kendiri",
    "pt-br": "Servico MyApp auto-hospedado",
    "ru": "Самостоятельно размещаемый сервис MyApp",
    "ko": "셀프 호스팅 MyApp 서비스",
    "zh-Hant": "自託管 MyApp 服務",
    "zh": "自托管 MyApp 服务",
    "tr": "Kendi barindirdiginiz MyApp servisi"
  },
  "readme": {
    "zh": {
      "introduction": "是一款自托管应用服务。",
      "features": [
        {
          "title": "容器化部署",
          "description": "支持通过 Docker 方式部署和运行。"
        }
      ]
    },
    "en": {
      "introduction": "is a self-hosted application service.",
      "features": [
        {
          "title": "Containerized Deployment",
          "description": "Supports deployment and runtime through Docker."
        }
      ]
    }
  },
  "type": "tool",
  "tags": ["Tool"],
  "website": "https://example.com",
  "github": "https://github.com/example/myapp",
  "document": "https://docs.example.com",
  "source_evidence": [
    "https://github.com/example/myapp/blob/main/docker-compose.yml",
    "https://docs.example.com/install/docker"
  ],
  "init_source_evidence": [
    "https://github.com/example/myapp/blob/main/Dockerfile"
  ],
  "architectures": ["amd64", "arm64"],
  "services": [
    {
      "name": "myapp",
      "image": "ghcr.io/example/myapp:1.0.0",
      "primary": true,
      "user": "1000:1000",
      "ports": [
        {
          "host_env": "PANEL_APP_PORT_HTTP",
          "host_default": 8080,
          "container": 3000,
          "protocol": "tcp",
          "label": "Port"
        }
      ],
      "volumes": [
        "./data:/app/data"
      ],
      "volume_permissions": [
        {
          "path": "./data",
          "owner": "1000:1000",
          "mode": "755"
        }
      ],
      "environment": {
        "APP_ENV": "production",
        "APP_SECRET": "${APP_SECRET}"
      }
    }
  ],
  "form_fields": [
    {
      "default": "myapp",
      "envKey": "APP_SECRET",
      "labelEn": "App Secret",
      "labelZh": "App Secret",
      "random": true,
      "required": true,
      "type": "password"
    }
  ]
}
```

## Fields

- `key`: required. Lowercase app key and folder name.
- `name`: required. Display name.
- `title`: optional; defaults to `name`.
- `version`: required. Do not prefix with `v`.
- `description`: string or i18n object. Prefer the full appstore language map.
- `readme`: optional object with `zh` and `en` sections. Each section supports `introduction` and `features`; use official app README/docs as the source.
- `english_supported`: optional boolean. Defaults to true. Set false only when the upstream app and official docs do not support English.
- `type`: `website`, `runtime`, or `tool` unless the repo already uses a more specific accepted value.
- `tags`: list of appstore tag keys.
- `services`: required. One or more Docker services.
- `form_fields`: optional. Extra fields for version `data.yml`; port fields are auto-added from service `ports`.
- `logo`: optional local path to a PNG file. If absent, the generator creates a tiny placeholder PNG.
- `source_evidence`: optional but strongly recommended. List the repository files or official docs used to determine Docker image, ports, volumes, environment variables, and dependencies.
- `notes`: optional package assumptions or warnings for the spec/final response. The generator does not write these into app README files.
- `init_commands`: optional list of shell lines to include in `scripts/init.sh`. Use only for official host-side preflight actions.
- `init_source_evidence`: required when `init_commands`, `init_permissions`, or `volume_permissions` generate an `init.sh`. List the official repository files or docs that justify the init actions.
- `init_permissions`: optional list of persisted host paths that need ownership or mode changes before the container starts. Use only when official Docker evidence confirms the container writes as a non-root user.

## Service Fields

- `name`: service key.
- `image`: pinned image reference.
- `primary`: true for the main app service.
- `ports`: list of host/container port mappings.
- `volumes`: list of compose-style relative mounts.
- `environment`: object or list.
- `command`, `entrypoint`: string or list.
- `user`: optional Compose runtime user, when required by the official image or Compose file.
- `depends_on`: list of service names.
- `healthcheck`: compose-compatible object.
- `volume_permissions`: optional service-local list of host paths to fix in `scripts/init.sh`.

## I18n Fields

Root `description` and every form field `label` should cover:

```text
en, es-es, ja, ms, pt-br, ru, ko, zh-Hant, zh, tr
```

For app-specific text, provide reviewed translations in the spec when available. If there is no reliable translation source, use the official English text as fallback instead of inventing details.


path: references/appstore-format.md
content: # 1Panel Appstore Format

Generated packages should follow the local app shape accepted by 1Panel.

```text
apps/<app-key>/
  logo.png
  README.md
  README_en.md
  data.yml
  <version>/
    data.yml
    docker-compose.yml
    <persistent-dir>/
      .gitkeep
    scripts/
      init.sh
```

`README_en.md`, persisted directories, and `scripts/init.sh` are generated only when needed.

## Root `data.yml`

Required fields:

- `name`
- `tags`
- `title`
- `description`
- `additionalProperties.key`
- `additionalProperties.name`
- `additionalProperties.tags`
- `additionalProperties.shortDescZh`
- `additionalProperties.shortDescEn`
- `additionalProperties.description`
- `additionalProperties.type`
- `additionalProperties.crossVersionUpdate`
- `additionalProperties.limit`
- `additionalProperties.architectures`

`additionalProperties.description` must include:

```text
en, es-es, ja, ms, pt-br, ru, ko, zh-Hant, zh, tr
```

## Version `data.yml`

Use `additionalProperties.formFields` for user-editable values.

- Every public port uses `rule: paramPort` and `type: number`.
- Secrets use `type: password`.
- Every form field `label` includes the full appstore language set.
- Every `${...}` variable used in Compose must be declared here, except known 1Panel-provided variables such as `${CONTAINER_NAME}`.

## `docker-compose.yml`

- Primary service uses `container_name: ${CONTAINER_NAME}`.
- Secondary services use `container_name: ${CONTAINER_NAME}-<service>`.
- Every service joins `1panel-network`.
- `1panel-network` is external.
- Every service includes `labels.createdBy: Apps`.
- Public ports use `PANEL_APP_PORT_*`.
- Persisted host paths are relative, such as `./data:/app/data`.

## README Files

Keep app README files concise:

- Chinese: `## 产品介绍`, optional `## 主要功能`.
- English: `## Introduction`, optional `## Features`.

Do not include source evidence, generated-package diagnostics, or local testing notes in app README files.

## Init Script

Generate `<version>/scripts/init.sh` only when official sources require host-side initialization.

Common case:

- A container runs as a non-root user.
- A host-mounted persistent directory must be writable by that user.
- Official Dockerfile, Compose, or image docs provide the numeric UID/GID.

If no init action is needed, do not create `scripts/`.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)