Metadata-Version: 2.5
Name: 1panel-toolkit
Version: 0.18.2
Summary: Generate, check, fix and runtime-verify 1Panel app packages from a single spec.
Author: idkan
License: MIT License
        
        Copyright (c) 2026 idkan
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: 1panel,appstore,docker,packaging,self-hosted
Requires-Python: >=3.10
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 1panel-toolkit

一个 spec 进，一个经过验证的 1Panel 应用包出。

`p1` 把 1Panel 应用打包里重复的部分（10 语言 label、README 骨架、`.env.sample`
闭合、LF/BOM 归一化、strict-store 结构校验）变成代码，把真正需要判断的部分
（上游端口、运行用户、依赖拓扑）留给人或 agent，并且把踩过的坑变成可复用的
前置检查。

它不绑定任何 agent 宿主：CLI 可以独立使用，也可以由 skill 包装后交给 coding
agent 调用。

**核心路径不需要面板、不需要 SSH、不需要任何密钥**：`resolve` → `recipe` → `gen` →
`check` → `depth` → `fix` 全部离线跑，输入是仓库地址（或本地检出），输出是一个包。
`deploy` / `uninstall` / `remote` / `panel` 是**可选的附加能力**，用面板 API key
帮你检查部署情况、把失败解释清楚——不配也行，核心不依赖它们。

## 安装

```bash
uv tool install 1panel-toolkit     # PyPI 上当前是 0.3.0
p1 --version
p1 selftest        # 验收：规则数据、写法库、生成→校验链路是否都正常
```

从源码：

```bash
uv venv .venv
uv pip install -e . --python .venv/Scripts/python.exe
```

从构建产物：

```bash
uv build --out-dir dist
uv venv .venv-dist && uv pip install dist/*.whl --python .venv-dist/Scripts/python.exe
.venv-dist/Scripts/p1.exe selftest
```

## 用法

```bash
p1 check <app-package-dir>
p1 check <dir> --strict
p1 check <dir> --json
p1 rules lessons
p1 ports 203.0.113.10 10000,15000,8080
p1 patterns list
p1 patterns show compose/panel-db-env
p1 patterns find 数据库
p1 depth --corpus <官方apps目录>          # 哪些维度是应用特有的
p1 depth <包目录> --corpus <官方apps目录>  # 这个包是适配过的还是套模板
p1 resolve <仓库URL|本地检出目录>          # 抽取上游证据，回答 10 个生成决策
p1 resolve halo-dev/halo --out ./out/halo  # 同时写出报告/spec草稿/证据
p1 remote check                            # 目标主机的事实与空闲端口
p1 remote smoke <包目录>                    # 上传→起容器→探针→抓日志→清理（不传端口就用包自己的默认值）
p1 gen examples/demo-spec.json --out ./out --check   # 从 spec 生成包并立即校验
p1 gen examples/demo-db-spec.json --out ./out --check # 含面板数据库依赖与 init.sh 的示例
p1 gen ./out/app/draft-spec.json --allow-unresolved  # 明知缺证据也要生成（会记账）
p1 recipe list                          # 5 条"成套写法"：面板 DB / Redis / Mongo / MinIO / 组合
p1 recipe apply panel-postgres ./out/app/draft-spec.json --write  # 一键接线
p1 deploy ./out/p1-demo --param PANEL_APP_PORT_HTTP=15000   # 走面板 API 正式安装
p1 deploy ./out/p1-demo --param PANEL_APP_PORT_HTTP=15000 --probe   # 装完再敲一下端口
p1 fix <包目录> --check                    # 把 check 的结果幂等修掉
p1 form audit <包目录> --plan plan.json    # 表单里哪些输入框用户真的必须填？
p1 fix <包目录> --plan plan.json --check   # 按你改过的计划执行
p1 selftest                                # 验收：这个安装能不能用
```

`p1 ports` 区分三种状态：`open`（已有服务）、`closed`（主机可达但无人监听，
说明安全组已放行，可以直接用）、`filtered`（超时，被上游拦掉）。云服务器上做
冒烟测试前先跑一次，能避免把安全组问题当成打包问题。

## 它检查什么

规则组 `P0xx` 管包结构：根/版本 `data.yml` 的键位层级、必备元数据、
`architectures` 位置、tag 白名单、logo 尺寸；`P030` 查根 `key` 与目录名是否一致
（官方 882/882 一致），`P017` 提示 `v` 前缀的版本目录（官方 1617 个里只有 6 个这样），
`P031`/`P032` 查根元数据的**类型**（`limit`/`recommend`/`memoryRequired` 必须是整数，
`crossVersionUpdate` 等必须是布尔——写错会让面板同步直接失败：
`cannot unmarshal !!str \`512M\` into int`），`P033` 提示官方语料没用过的键。

规则组 `I0xx` 管 i18n：`description` 与 `formFields[].label` 的语言覆盖、
`values[].label` 必须是纯字符串、bool label 必须加引号。`I005` 对"`title` 与
`shortDescZh` 不是同一句"只报 warn：官方 882 个应用里 811 个写成同一句、
69 个把 `title` 当产品名，两种都能上架。

规则组 `C0xx` 管 compose：顶层 `version:`、image 引号、`${VAR}` 与表单 envKey
闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突、
`healthcheck` 必须是映射（`C005`，`healthcheck: true` 会让 Docker 直接解析失败）、
service 是否有 `restart` 策略（`C006`，官方 1954/1958 个服务都写了）、
以及 compose 里到底有没有 service（`C031`：`services: {}` 以前能全绿通过，
但那样装上去什么都不会跑）。

规则组 `H0xx` 管文件卫生：LF/BOM、README store 风格、`.env.sample` 闭合、
`scripts/*.sh` 必须有 shebang（`H030`，官方 2416/2416 个脚本都有）。

规则组 `F0xx` 管安装表单本身：字段预算（官方中位数 5 个、上限 20）、必填比例、
密钥字段类型、字段是否真被引用（`F007`），以及 `type`/`rule` 是否落在官方用过的枚举里
（`F008`/`F009`——表单就是靠这两个值渲染的）、每个字段键的 **YAML 类型**
（`F010`/`F011`：`description`/`label`/`child` 必须是字典、`values`/`params` 必须是列表，
写成字符串面板会直接同步失败）。

规则组 `L0xx` 是经验检查：静态校验通过但容器启动就崩的那类问题，条目见
`rules/data/lessons.yaml`。

规则本身是数据，放在 `src/p1toolkit/rules/data/*.yaml`：store 政策变化是改数据，
不是改代码。

## 写法库（patterns）

`p1 patterns` 是一套**从官方应用包里抽取的真实写法**，不是手写的示例：
当前语料是 **882 个官方应用 / 1617 个版本**，覆盖 data.yml 声明、各类表单字段、
数据库与 Redis 接入、compose 拓扑、init/upgrade/uninstall 脚本、README 与
`.env.sample` 约定。

每条 pattern 带四样东西：**用途**、**何时使用**、**官方采用数**（多少个应用真的
这么写，用来区分主流做法和个例）、以及**真实代码片段 + 来源包路径**。

```bash
p1 patterns list                       # 22 条写法，带采用数
p1 patterns show form-field/port       # 展开一条，含完整示例与来源
p1 patterns find 数据库                 # 关键词检索
p1 patterns build --corpus <apps目录>   # 重新从语料构建（官方 store 更新后跑一次）
p1 patterns export --markdown docs/patterns.md   # 生成教程文档
```

构建产物 `patterns/data/patterns.json`（约 37 KB）随包分发，所以离线也能查；
教程文档见 `docs/patterns.md`。

## 生成深度（depth）

模板化生成会做出高度重复的包——因为每个应用真正不同的那些维度（容器内挂载点 642 种
取值、容器内端口 359 种、应用环境变量名 3197 个）在模板里全被压成了一个默认值。

`p1 depth` 用 882 个官方应用的实测分布量化这件事：哪些维度可以模板化（重启策略 5 种
取值，`always` 占 82%），哪些必须逐应用决定。它还给出**单包判定**——把包的应用特有
特征做成指纹，和官方语料比对，识别"是适配过的"还是"套模板套出来的"。

生成器必须逐项回答的 10 个问题在 `rules/data/decisions.yaml`，说明见
`docs/generation-depth.md`。

## 上游情报（resolve）

`gen` 的每一笔都必须是能指回上游文件的值，所以先生成证据：

```bash
p1 resolve halo-dev/halo --out ./out/halo
```

它从 GitHub API（或本地检出目录）拉 Dockerfile / compose / `.env.example` /
README，抽取出端口、挂载点、环境变量、运行用户、额外运行时字段、依赖等信息，
**每条都带 `文件:行号`**，然后逐条回答 D01–D10：

`raw.githubusercontent.com` 在部分网络下不可达，另一些网络下是**极慢**（实测 4–17 秒/文件）。
`resolve` 的第一次请求只当探路（4 秒上限），超时或失败就改用 GitHub 的 contents API
取文件（同环境实测 0.7 秒/文件），并在报告里说明原因；之后各文件**并行抓取**。
只有仍在用 raw 时才受抓取总预算（默认 60 秒）限制，`--timeout` 可调，
`--timeout 0` 只取仓库元数据。实测一次远程 resolve 从 26 秒降到 7 秒左右；
本地检出则完全离线：`p1 resolve <本地目录>`。

- ✅ `evidenced` —— 上游文件里明写了
- ⚠️ `inferred` —— 有较弱的合法来源（例如路径来自 `HALO_WORK_DIR=/root/.halo2`
  这类环境变量默认值，镜像名来自 README 的 `docker run`），需要人工确认
- ❌ `missing` —— 什么都没有

**D03（容器内挂载点）、D05（容器内端口）、D06（镜像）没有证据时，`resolve` 直接
返回非零退出码并列出阻塞项**，不猜、不填默认值。间接证据（inferred）会连同答案一起
落进 draft spec——人工确认过的值必须能到达生成器，而不是停在报告里。产出三份文件：
`decision-report.md`、`draft-spec.json`、`resolve.json`。

**多服务 compose 按服务归属**：compose 里每个服务在 draft spec 里各占一条，镜像、容器端口、
bind 挂载、环境变量、`depends_on`、运行时字段都只跟着自己的服务走。Dockerfile 里的变量归
主服务；sidecar（自带的数据库/缓存）的密码不会被并到应用服务上，反过来也一样。多服务时还会给
一条提示：如果面板上已经有对应的应用，考虑改用 `dependencies` 走面板托管（D10）。

实测：`halo-dev/halo` 的挂载点在 Dockerfile 里没有 `VOLUME`，但 `HALO_WORK_DIR`
默认值是 `/root/.halo2`；镜像只在 README 里出现——它把两条都标成 `inferred`
并给出出处，而不是停下来或者瞎猜。

## 运行时验证（remote）

### SSH 冒烟（remote smoke）

静态校验和生成的 YAML 都不能告诉你"容器起不起来、端口通不通、应用找不找得到配置文件"。

```bash
p1 remote check                                    # 主机事实（内存/磁盘/已用端口/已有本地应用）
p1 remote smoke <包目录>                            # 完整一轮冒烟（用包自己的默认端口）
p1 remote smoke <包目录> --ports 10000              # 显式覆盖宿主端口
p1 remote smoke <包目录> --env OV_VLM_API_KEY=sk-…  # 用真实密钥覆盖表单默认值
```

它把某个版本目录上传到 `/tmp/p1-smoke/<key>`（**不是** `/opt/1panel/resource/apps/local`，
不会碰到你已装的包），按安装表单的默认值渲染 `.env`，`docker compose up -d`，
然后轮询到"HTTP 有应答"或"healthcheck 明确 healthy"为止，抓日志、清理。

不传 `--ports` 时用的是**包自己的表单默认端口**——冒烟要验的就是这个包，不是
"在这台机器上换个端口能不能跑"。端口被占用时失败信息会点名 `L008`：
那不是包的缺陷，1Panel 安装时会提示换一个端口，SSH 冒烟用 `--ports` 指定即可。

判定分三类，退出码不同，便于接 CI：

| 结果 | 退出码 | 含义 |
| --- | --- | --- |
| `PASS` | 0 | 容器 running 且端口有 HTTP 应答（或 healthcheck healthy） |
| `PENDING` | 3 | 日志显示缺少用户密钥/凭据 → **是表单待填，不是包的缺陷** |
| `FAIL` | 1 | 其他失败；自动匹配 lesson 并给出根因 |

两个设计细节值得说明：**端口被监听不等于就绪**（应用可能先绑端口再花几十秒初始化），
所以主判据是 HTTP 应答，纯 TCP 监听只在 healthcheck 认可时才算通过；失败时保留远端目录
以便 `p1 remote logs <dir>` 复看，成功则连同容器和数据卷一起清掉。

### 面板安装（deploy / uninstall）

```bash
p1 deploy <包目录> [--param K=V] [--uninstall] [--cleanup-local] [--no-wait]
p1 uninstall <安装名>          # 或 --install-id N
```

和 `remote smoke`（SSH 直接起容器）不同，`deploy` 走的是**真实用户路径**：

1. 把包放到面板的本地应用目录 `/opt/1panel/resource/apps/local/<key>`
2. `POST /apps/sync/local` 让面板重新扫描本地应用
3. `POST /apps/search` 找到应用（本地应用注册成 `local<key>`）
4. `GET /apps/detail/{appId}/{version}/{type}` 取 `appDetailId`
5. `POST /apps/install` 提交安装，参数以**对象**形式放进 `params`
6. 轮询 `POST /apps/installed/search`，看面板自己记的 `status` 是否 `Running`

`Running` 只说明容器起来了，不代表服务能应答——端口映射写错、应用启动即崩、
初始化失败都会是 `Running`。加 `--probe` 就会在装完后从服务器本机敲一下端口：
不带值用安装参数里的 `PANEL_APP_PORT_*`（否则用表单默认值），也可以 `--probe 15000`
指定。判定与 `remote smoke` 共用：HTTP `< 500` 算有应答（401/403/404 也算），
否则回落到 TCP 监听判定；探针失败时 `stage=probe`、退出码 1，`--uninstall` 与
`--cleanup-local` 照样执行，不会在服务器上留坏安装或残留包。

这几步的端点与字段都是从运行中的 v2.2.4 上探测出来的（给接口发空 body，让面板的
参数校验器报出必填项），不是猜的。实测一轮：安装 → 容器 `status=Running` → 卸载，
约 10 秒（镜像已缓存）。

为什么两个都要有：`remote smoke` 验证"这个 compose 能不能起来"，
`deploy` 验证"面板能不能把它装出来"——表单渲染、依赖注入、防火墙放行这些环节
只有走面板才会被走到。

### 1Panel v2 的 API 鉴权（踩过的坑）

1Panel v2.2.4 的接口约定和直觉不一样，而且**三处错误返回同一条**
`{"code":401,"message":"API 接口密钥错误"}`，从响应上完全看不出问题在哪：

| | 正确做法 | 直觉上会写成 |
| --- | --- | --- |
| 路由前缀 | `/api/v2/...` | `/api/v1/...`（v2 里已不存在） |
| 时间戳头 | `1Panel-Timestamp` | `1Panel-Time` |
| Token 头 | `md5("1panel" + api_key + 时间戳)` | 明文 api_key |

依据是上游 `backend/middleware/session.go`：
`panelToken == GenerateMD5("1panel" + global.CONF.System.ApiKey + panelTimestamp)`。
`p1 config set panel.*` + `p1 panel ping` 已按这个实现（保留 v1 回退），
lesson 记作 `L007`。

## 修复（fix）

```bash
p1 fix <包目录> --dry-run     # 先看会改什么
p1 fix <包目录> --check       # 改完立刻重新校验
```

`fix` 是幂等的：第二次跑会告诉你"没有需要修复的内容"。它做两类事：

**机械修复**（永远安全）：CRLF/BOM 归一化为 LF、`image` 与端口映射加引号、
移除顶层 `version:`、补 `labels.createdBy`、补根 `data.yml` 的 `recommend` /
`memoryRequired`、补**空**的 `title`/`description`、补齐 `.env.sample`
缺失的变量、把 logo 等比缩放到 180×180 透明底（**不放大**，小图居中，
支持官方语料里出现的全部位深、色彩类型与 `tRNS` 透明——567 个官方 logo 是
带透明背景的调色板 PNG（另有 2 个是带透明色的 RGB），漏掉它就是让透明底变成一块实色）。

它**不会改写你已经写好的 `title` 和 `description`**：`title` 是商店里的应用名，
`description` 是人写的简介。官方 882 个应用里有 69 个（7.8%）把产品名放在 `title`、
一句话放在 `shortDescZh`，`p1 check` 对这种不一致只报 warn——替你把名字改掉才是破坏。

**纪律修复**（改变安装表单）：把"可选且有可用默认值"的字段从表单里移出去，
同时在 compose 里把 `${VAR}` 改成 `${VAR:-默认值}`，让变量仍然有效。这就是
"三十个输入框、六个必填"那个反模式的解法。

不做的事：需要判断的（哪些字段用户真的必须决定、标签该怎么翻译）会列入
`需要人工处理` 而不是替你猜。

那部分判断现在有个半自动出口：`p1 form audit` 把每个字段分成
`keep`（用户必须决定）/ `hide`（可收进 compose 默认值）/ `review`（表单超预算时，
必填带默认值但名字不属于官方约定的字段）/ `unused`（没人引用）/ `must-fill`
（必填又没默认值），并写出计划骨架。判定依据是实测：官方 10640 个字段里 **73% 就是
"必填 + 有默认值"**，所以那本身不是问题信号；名字命中官方约定（时区、路径、PUID/PGID、
端口、密钥、库名、引擎/模式、资源参数）的一律保留，判定规则在 `rules/data/form-review.yaml`，
改判断是改数据。
你只改 `hide` / `keep` 两个列表，`p1 fix --plan plan.json` 负责机械执行：
被隐藏的字段会在 compose 里拿到 `${VAR:-默认值}`，变量不会失效；必填且没有默认值的字段
会被拒绝隐藏（不会把"用户必须填"偷偷变成空值）。计划是幂等的，同一个 plan 跑两次等价。

实测效果（`_recon/measure_fix.py` 的原始输出）：

| 包 | 修复前 | 修复后 | 改动 |
| --- | --- | --- | --- |
| 应用 A | 1 fail + 9 warn | **0 fail + 3 warn** | 5 处 |
| 应用 B | 0 fail + 7 warn | **0 fail + 2 warn** | 6 处 |
| 应用 C | 0 fail + 15 warn | **0 fail + 2 warn** | 12 处 |

（2026-09-20 在真实应用包上的实测记录，修复前后都用同一条命令复算
——`python _recon/measure_fix.py --baseline out/fix-backup-20260920`。
数字按当时的规则版本记录，规则变化会让同一份包的数字变化，例如 `I005` 从 fail
降为 warn 之后，"修复前"那一列就少一个 fail、多一个 warn。
包名从略——这份 README 只需要证明"跑得通"，不需要谁的包清单。）

## 生成（gen）

```bash
p1 resolve <仓库> --out ./out/app     # 1. 上游证据 → draft-spec.json
$EDITOR ./out/app/draft-spec.json     # 2. 人工确认证据、补一句简介
p1 gen ./out/app/draft-spec.json --out ./out --check   # 3. 生成 + 校验
p1 remote smoke ./out/app --ports 10000                # 4. 真机验证
```

**核心纪律：安装表单是"决策清单"，不是"配置转储"。**

一个上游变量只有在两种情况下才出现在表单里：用户必须决定它（端口、对外 URL），
或者用户必须提供它（密钥）。其余全部写进 compose 默认值（`${VAR:-default}`），
并在 README 的「高级配置」里列出来供需要时修改。

这不是审美偏好，是实测差距：官方 1617 个版本的表单**中位数 5 个字段、88% 标为必填**；
而手写包很容易做出 30 个字段、必填只占 20% 的表单，用户反馈"有的要填有的不用填，
非常麻烦"。现在 `gen` 会把这个纪律强制执行，`check` 的 F 组规则负责事后把关。

生成物包含：根 `data.yml`（`name` 放产品名，`title`/`description`/`shortDescZh`
同一句简介 —— 这是 739/771 个官方应用的写法）、版本 `data.yml`、`docker-compose.yml`
（引号、`createdBy`、`1panel-network`、上游 runtime 开关原样保留）、`.env.sample`
（包含所有隐藏变量）、中英 README、logo 占位图、`source-evidence.json`，
以及 `check` 会提醒的 `recommend` / `memoryRequired`（默认 0，spec 可覆盖）——
生成物本身就该是"不用立刻再 fix 一遍"的。

**一个服务可以开多个端口**：`container_ports: [6099, 3001]` 会生成两个
`PANEL_APP_PORT_*` 表单字段和两条端口映射（WebUI + 协议端口这类应用很常见），
`container_port` 仍是单端口简写。

**一个 spec 可以出多个版本目录**：官方 882 个应用里 656 个（74%）是多版本，
版本之间的差别 709 对只是镜像标签。写成

```json
"version": "latest",
"versions": [
  "latest",
  { "version": "1.4.2", "image": "example/app:1.4.2" },
  { "version": "1.3.0", "service": "cache", "image": "redis:6-alpine" }
]
```

清单第一项是商店默认展示的版本，根目录的 `data.yml` / README / logo 只写一份。
覆盖可用的键：`image` / `container_port` / `container_ports` / `mounts` /
`mount_targets` / `mount_host` / `env`（与主版本合并）/ `services`（整组替换）。
多服务时覆盖默认落在**第一个服务**上；要改别的服务就写 `"service": "<服务名>"`，
点错名字会被拒绝——不点名而 spec 又不止一个服务时，运行报告会写明这次改的是谁。

生成过程会明确告诉你哪些东西是**它替你决定的**：哪些变量被收进默认值、哪些翻译是
占位、logo 需要替换。

**把"用户得自己做的事"写进 README**：官方 882 份 README 里 54% 有参数说明表、30% 提到
用户自备的文件（模型、证书、插件）。spec 的 `docs` 块让生成器替你写这两节：

```json
"docs": {
  "first_run": "启动前先把模型放进 ./models，否则容器会退出。",
  "parameters": {
    "PANEL_APP_PORT_HTTP": "面板入口端口，默认 8000；被占用时面板会提示换一个",
    "OV_VLM_API_KEY": "调用上游服务的密钥；留空则用本地模型"
  },
  "assets": [
    { "path": "./models/voice.pt", "target": "/app/models/model.pt",
      "why": "语音识别模型，容器不会自带", "source": "https://example.com/models" }
  ]
}
```

生成的 README 会多出「配置项」（变量｜说明｜默认值｜必填）与「需要你自己准备的文件」
（宿主路径 → 容器内路径、为什么需要、从哪拿），`first_run` 覆盖默认的「首次启动」那句；
被收进默认值的隐藏变量在「高级配置」里逐条带说明，不再只是一串变量名。
三条校验兜底：变量名拼错、路径没被挂载、只给路径不给理由——都会在生成时报出来。

### 证据纪律：没有证据就不生成

`gen` 会先审计 `resolve` 写下的决策账本，再决定要不要写文件。**D03（容器内挂载点）、
D05（容器内端口）、D06（镜像）没有证据时直接拒绝生成**，退出码 2，且一个文件都不写：

```
FAIL D03    D03 数据要挂到容器内哪个路径？ 没有上游证据
            → 补证据，或在 spec 里写 "acknowledged": {"D03": "理由"}；停止，不要猜挂载点
FAIL D06    app: 没有 image
            → 镜像必须来自上游证据（README 的 docker run / 官方镜像页）
```

这三条是实测出来的分界线：它们的取值分别有 642 / 359 / 19 种，抄模板抄错时的表现是
"装得上、跑得起来、就是不对"——数据升级即丢、端口不通、镜像来路不明。阻塞范围存在
`rules/data/decisions.yaml` 的 `blocking` 字段里，`resolve` 与 `gen` 读同一份数据。

其余检查不阻塞，但会点名：

- **漂移**：spec 的镜像 / 端口 / 挂载点不在账本证据里，或环境变量名不在 D01/D02 里
  （变量名拼错是最难查的一类：容器照常启动，值被静默忽略）。
- **结构性缺口**：声明了 `mount_host` 却没有容器内挂载路径的 service 会被拒——
  这种包"有数据目录"但什么都没挂进去。
- **非阻塞决策缺证据**：把 `decisions.yaml` 里 `if_unknown` 的处理方式原样打印出来。

人工可以显式承担决定，两种方式都会记进包内的 `decision-audit.json`：

```bash
# 1. 写进 spec（推荐：评审时能看到理由）
#    "acknowledged": {"D03": "上游 Dockerfile 没有 VOLUME，这个应用不写盘"}
# 2. 一次性放行
p1 gen ./out/app/draft-spec.json --allow-unresolved
```

`decision-audit.json` 与包一起交付：每条决策的状态、答案、证据缺口、谁批准了哪条，
评审的人不必再去翻对话记录。`p1 gen --json` 里也有同一份数据，方便接 CI。

### 依赖接入（D10）与脚本（D09）

**依赖是"接线"，不是"多几个输入框"。** 官方 106 个版本用依赖选择器，主流形状是两步式
`type: apps` + `child.type: service`（72 个版本），一步式 `type: service` 有 53 个
（Redis 只有一种实例，天然用一步式）。两种形状都从语料量出来，放在
`rules/data/dependencies.yaml`。

```json
"dependencies": [
  {
    "kind": "postgresql",
    "kinds": ["postgresql", "mysql"],
    "env": {
      "DATABASE_HOST": "${PANEL_DB_HOST}",
      "DATABASE_URL": "postgres://${PANEL_DB_USER}:${PANEL_DB_USER_PASSWORD}@${PANEL_DB_HOST}:${PANEL_DB_PORT}/${PANEL_DB_NAME}"
    }
  }
]
```

生成器据此产出选择器字段、把 `${PANEL_DB_*}` 映射成应用自己的变量名，并**只为真正被
引用到的面板变量生成输入框**（`PANEL_DB_HOST` 由选择器提供，其余没用到的一个都不加）。
应用侧的变量名必须由你给出——那是 D01 证据，不是生成器能发明的东西。

`p1 resolve` 会先给线索：上游 compose / README 里像数据库接线的变量（`DB_HOST`、
`DATABASE_URL`、`REDIS_*`）会按 host / port / user / password / name / url 归类，
写进 `draft-spec.json` 的 `dependency_hints` 和决策报告的「D10 线索」一节。

**脚本与首次启动配置**（lesson L001：静态全绿、容器一起来就退出）有了两条正解：

```json
"data_owner": "1000:1000",
"config_files": [
  { "path": "data/app.conf", "content": "database_url=${DATABASE_URL}\n" }
]
```

`data_owner` 生成 `init.sh` / `upgrade.sh`，按挂载目录 `mkdir -p` + `chown -R`；
`config_files` 把模板放进版本目录，并在中英文 README 的「首次启动 / First start」里
提醒用户先编辑。更复杂的逻辑用 `scripts: {init, upgrade, uninstall}` 原样写入
（自动补 `#!/bin/bash`、统一 LF）。

这两件事同样受证据约束：声明 `user` / `data_owner` 却拿不出 D07 证据、带了依赖而
D10 说"未检测到"、带了脚本而 D09 无证据——都会在证据审计里被点名（lesson L004）。

### 配方（recipes）：把成套写法变成一条命令

```bash
p1 resolve <仓库> --out ./out/app                                  # 上游证据 + D10 线索
p1 recipe apply panel-postgres ./out/app/draft-spec.json --write   # 一键接线
p1 gen ./out/app/draft-spec.json --check                           # 生成 + 校验
```

配方只填空的那部分是**结构**：面板变量怎么映射到应用变量、选择器什么形状、哪些变量得由
包自己声明。应用侧的变量名一律来自 `resolve` 的 `dependency_hints`（或 `--bind` 显式给出），
配方不发明变量名，也不替人决定要不要接依赖。

五条内置配方：`panel-postgres`（PostgreSQL / MySQL / MariaDB，两步式选择器）、
`panel-redis`、`panel-mongo`、`panel-minio`、`panel-db-and-cache`（复用前两条）。
`p1 recipe show <id>` 展开角色表；
`p1 recipe apply` 默认只预览，加 `--write` 写回 spec；`--bind host=DB_HOST` 补 hints 没覆盖的
角色。hints 的 `subjects` 会区分 `postgresql` 与 `redis` 这类**依赖主体**，所以
`REDIS_URL` 不会被绑到 Postgres 的连接串模板上。

**配方里的形状都有出处**：`p1 recipe show <id>` 会列出这条配方依据的官方写法
（每条带"多少个官方应用采用"、示例来自哪个包、以及一小段真实片段），
例如 `form-field/apps-child-service`（41 个应用）就是两步式选择器的来源；
`p1 patterns show <id>` 反过来会告诉你"这条写法被哪些配方用着"。

## 当前状态

完整流程已经闭环：

```bash
p1 resolve <仓库> --out ./out/app        # 1. 上游证据 + 10 项生成决策
# 人工确认 draft-spec.json
p1 gen ./out/app/draft-spec.json --check # 2. 生成 + 静态校验
p1 depth ./out/app --corpus <官方apps>    # 3. 确认不是模板复制品
p1 remote smoke ./out/app --ports 10000  # 4. SSH 冒烟：起容器、探针、抓日志
p1 deploy ./out/app --param ... --uninstall  # 5. 走面板 API 正式安装并验证
```

修复已有包：`p1 fix <包目录> --check`（幂等，见上文）。

命令一览（16 个）：`resolve` / `recipe` / `gen` / `check` / `form` / `fix` / `depth` /
`patterns` / `ports` / `remote` / `deploy` / `uninstall` / `config` / `panel` / `rules` /
`selftest`——其中前 8 个（到 `patterns`）离线可用，后面几个是可选的面板/SSH 能力。

## 文档

| 文档 | 内容 |
| --- | --- |
| 本文件 | 用户视角：装什么、怎么用、为什么这么设计 |
| `docs/architecture.md` | **接手先读**：模块地图、证据链、扩展点、测试策略 |
| `docs/status.md` | 工具状态：命令清单、规则与数据规模、实测数据、验证记录、已知限制、维护手册 |
| `docs/generation-depth.md` | 为什么模板化生成会做出重复的包（实测分布 + 判定口径） |
| `docs/patterns.md` | 22 条官方真实写法的教程（`p1 patterns export` 生成） |
| `CHANGELOG.md` | 每个版本改了什么、为什么改 |

## 设计约束

没有官方 Docker 证据就不猜镜像、端口、卷、UID/GID。高风险运行时权限先保留并
标注风险，不为了扫描器好看而删。云服务器上做运行时验证时只用已确认放行的端口
段，避免把安全组问题误判成打包问题。

## 它不做什么

这条界线值得写下来，因为它划错时工具会开始骗人：

- **不把在线步骤变成必需。** 面板与 SSH 是可选的辅助；核心（进 GitHub 地址、出包）
  完全离线可用，已经有一条测试盯着这件事（配置指向不存在的文件时，
  `gen` / `check` / `form audit` / `depth` 必须照常通过）。
- **不替你挑宿主端口。** 端口是包自己的决定（表单默认值），冲突由 1Panel 在安装表单里
  提示、由 Compose 报错。工具不会为了"跑通"而在服务器上另找一个空闲端口来装——
  那样测的就不是这个包了。`remote smoke --ports` 是**显式覆盖**，不是自动兜底。
- **不为某一台服务器调优。** 端口区间、安全组放行情况、镜像缓存都随机器和时间变化；
  工具只根据它当场读到的这台主机的事实做事（`p1 remote check`），不预置任何
  服务器专属常量。
- **不假装知道上游没写的东西。** 镜像、容器端口、挂载点没有证据就拒绝生成；
  应用自己的变量名要么来自证据、要么由你给。
- **不做需要判断的事。** 哪些字段用户真必须填、标签怎么翻译、要不要接面板依赖，
  工具列清单、给依据，但不替你决定（`p1 form audit` + `--plan` 就是为这条设计的）。
