1panel-toolkit 0.3.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/CHANGELOG.md +80 -0
  2. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/PKG-INFO +31 -2
  3. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/README.md +30 -1
  4. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/pyproject.toml +1 -1
  5. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/skill/SKILL.md +3 -1
  6. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/__init__.py +1 -1
  7. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/compose.py +16 -0
  8. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/cli.py +92 -0
  9. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/gen.py +15 -2
  10. 1panel_toolkit-0.5.0/src/p1toolkit/recipes.py +253 -0
  11. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/resolve/__init__.py +143 -64
  12. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/resolve/analyze.py +61 -0
  13. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/__init__.py +19 -0
  14. 1panel_toolkit-0.5.0/src/p1toolkit/rules/data/recipes.yaml +59 -0
  15. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/selftest.py +34 -0
  16. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_checks.py +28 -0
  17. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_gen.py +45 -0
  18. 1panel_toolkit-0.5.0/tests/test_recipes.py +182 -0
  19. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_resolve.py +96 -0
  20. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_selftest.py +1 -0
  21. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/.gitignore +0 -0
  22. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/LICENSE +0 -0
  23. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/docs/generation-depth.md +0 -0
  24. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/docs/patterns.md +0 -0
  25. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/examples/demo-db-spec.json +0 -0
  26. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/examples/demo-spec.json +0 -0
  27. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/__init__.py +0 -0
  28. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/forms.py +0 -0
  29. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/hygiene.py +0 -0
  30. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/i18n.py +0 -0
  31. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/lessons.py +0 -0
  32. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/structure.py +0 -0
  33. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/checks/substance.py +0 -0
  34. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/config.py +0 -0
  35. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/dependencies.py +0 -0
  36. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/deploy.py +0 -0
  37. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/depth.py +0 -0
  38. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/errors.py +0 -0
  39. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/evidence.py +0 -0
  40. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/fix.py +0 -0
  41. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/lifecycle.py +0 -0
  42. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/loader.py +0 -0
  43. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/logo.py +0 -0
  44. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/model.py +0 -0
  45. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/net.py +0 -0
  46. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/panel.py +0 -0
  47. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/patterns/__init__.py +0 -0
  48. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/patterns/catalog.py +0 -0
  49. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/patterns/data/patterns.json +0 -0
  50. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/patterns/extract.py +0 -0
  51. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/remote.py +0 -0
  52. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/report.py +0 -0
  53. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/resolve/sources.py +0 -0
  54. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/decisions.yaml +0 -0
  55. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/dependencies.yaml +0 -0
  56. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/envkeys.yaml +0 -0
  57. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/form-design.yaml +0 -0
  58. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/i18n.yaml +0 -0
  59. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/lessons.yaml +0 -0
  60. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/src/p1toolkit/rules/data/store.yaml +0 -0
  61. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/conftest.py +0 -0
  62. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_config.py +0 -0
  63. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_corpus.py +0 -0
  64. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_dependencies.py +0 -0
  65. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_deploy.py +0 -0
  66. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_depth.py +0 -0
  67. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_evidence.py +0 -0
  68. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_fix.py +0 -0
  69. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_forms.py +0 -0
  70. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_github_source.py +0 -0
  71. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_lifecycle.py +0 -0
  72. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_net.py +0 -0
  73. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_panel.py +0 -0
  74. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_patterns.py +0 -0
  75. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_regressions.py +0 -0
  76. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_remote.py +0 -0
  77. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_remote_cli.py +0 -0
  78. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.5.0}/tests/test_rules_data.py +0 -0
@@ -1,5 +1,85 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.5.0 — 2026-09-20
4
+
5
+ 配方(recipes):把"一类应用的成套写法"变成一条命令。
6
+
7
+ ### 配方是什么,不是什么
8
+
9
+ 配方只填空的那部分是**结构**——面板变量到应用变量怎么映射、选择器什么形状、
10
+ 哪些变量必须由包自己声明。应用侧的变量名永远来自 `p1 resolve` 的
11
+ `dependency_hints` 或显式的 `--bind`,配方不发明变量名,也不替人决定要不要接依赖。
12
+
13
+ ```bash
14
+ p1 resolve <仓库> --out ./out/app # 1. 上游证据 + D10 线索
15
+ p1 recipe apply panel-postgres ./out/app/draft-spec.json --write # 2. 一键接线
16
+ p1 gen ./out/app/draft-spec.json --check # 3. 生成 + 校验
17
+ ```
18
+
19
+ - `p1 recipe list` / `p1 recipe show <id>`:三条配方——`panel-postgres`(PostgreSQL /
20
+ MySQL / MariaDB,两步式选择器)、`panel-redis`、`panel-db-and-cache`(复用前两条,
21
+ 角色表只有一份)。数据在 `rules/data/recipes.yaml`。
22
+ - `p1 recipe apply <id> <spec>` 默认只预览,`--write` 写回 spec;`--bind <角色>=<变量>`
23
+ 补上 hints 没覆盖的角色,`--service <名字>` 指定接到哪个服务上。
24
+ - `require: [host|url]` 表示"二选一即可":一个连接串本身就带了主机、端口、库名和凭据,
25
+ 这时不该再要求单独的 HOST。
26
+ - 重复 apply 不会产生重复依赖(同 kind 的旧接线被替换)。
27
+
28
+ ### 顺带修掉的 hints 陷阱
29
+
30
+ 原来 `dependency_hints.keys` 把所有 URL 混在一个 `url` 角色里,于是
31
+ `REDIS_URL` 会被 `panel-postgres` 绑到 Postgres 的 URL 模板上——包能生成,
32
+ 装出来连的是错的库。现在 hints 增加了 `subjects`(按依赖主体分组:
33
+ `postgresql` / `redis` / `generic-db`…),配方只绑同一 family 的变量;
34
+ `DB_HOST` 这类泛型变量留在 `generic-db` 里,同时可供任何 DB 引擎复用。
35
+ 线索的 note 也会直接给出该跑哪条配方。
36
+
37
+ ### 验证
38
+
39
+ - 实测链路:对一个带 `DATABASE_URL` / `DB_HOST` / `REDIS_URL` 的检出跑
40
+ `resolve` → `recipe apply panel-postgres --write` → `gen --check` → `0 fail`,
41
+ 表单里出现两步式选择器与 `PANEL_DB_*` 字段,compose 里是字面接线。
42
+ - 新增 12 条测试(绑定、跨主体不串味、缺失必需角色、`--bind`、幂等、
43
+ 组合配方、CLI 预览/写回/未知配方、配方生成的包含法校验),`pytest` 277 passed,
44
+ `p1 selftest` 9/9。
45
+
46
+ ## 0.4.0 — 2026-09-20
47
+
48
+ 多服务包:draft spec 从"只有主服务"变成"compose 里每个服务各一条"。
49
+
50
+ ### 为什么
51
+
52
+ 0.3.1 修了挂载点跨服务错配,但 `image` / `container_port` / `env` 仍然是跨服务聚合的:
53
+ `draft_spec` 取 `images[0]`、`ports[0]`,并把所有服务的环境变量合并成一份。一个
54
+ "应用 + 自带数据库"的 compose 会因此生成**一个** spec service,里面塞着 sidecar 的
55
+ 镜像、端口、甚至数据库密码。openviking(openviking + caddy)实测就是这种形态:
56
+ caddy 的 `OV_ACME_EMAIL` 会出现在主服务上,反过来 caddy 只拿到自己的变量。
57
+
58
+ ### 改了什么
59
+
60
+ - 新增 `analyze.service_facts()`:按服务取出镜像、端口、bind 挂载、环境变量、
61
+ `depends_on`、运行时字段(security_opt / user / devices …)与 healthcheck。
62
+ - `draft_spec` 为每个服务生成一条 spec(compose 顺序,第一条仍是主服务、拿
63
+ `PANEL_APP_PORT_HTTP`,其余服务的容器名带后缀)。Dockerfile 里的变量只归主服务,
64
+ 且不会重复落到已经声明过它的服务上;sidecar 的凭据不会被并进应用服务。
65
+ - 多服务时加一条 note:这些服务已按归属写进 draft,如果面板上已有对应应用
66
+ (postgresql / redis / mysql),考虑改用 `dependencies` 走面板托管(D10)。
67
+ - **healthcheck**:上游的 healthcheck 块(`test` / `interval` / …)会原样带进 spec 和
68
+ 生成的 compose;上游只有"存在 healthcheck"这一事实时,draft 只写一条提示,
69
+ 不再输出 `healthcheck: true`——那不是定义,Docker 会直接解析失败。新增规则
70
+ **C005** 拦住手写的非映射 healthcheck。
71
+ - 表单字段顺序:端口字段统一排在前面,多服务时不会被另一个服务的变量隔开。
72
+
73
+ ### 实测
74
+
75
+ - openviking(openviking + caddy):draft 从 1 条(镜像/端口/挂载/变量全混)变成
76
+ 2 条各自正确——openviking 拿到自己的镜像、1933、`/app/.openviking`、
77
+ Dockerfile 变量与真实 healthcheck 块;caddy 拿到 `caddy:2`、1934、
78
+ `./Caddyfile:/etc/caddy/Caddyfile`、`depends_on: [openviking]` 与自己的变量。
79
+ `p1 gen --check` → `0 fail`。
80
+ - 新增 9 条测试(多服务归属、sidecar 凭据不串味、healthcheck 块 vs 标志、
81
+ C005、多服务生成),`pytest` 265 passed。
82
+
3
83
  ## 0.3.2 — 2026-09-20
4
84
 
5
85
  `p1 depth` 的判定阈值校准。用 `p1 fix` 收敛用户自己的四个包时发现的误判。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 1panel-toolkit
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: Generate, check, fix and runtime-verify 1Panel app packages from a single spec.
5
5
  Author: idkan
6
6
  License: MIT License
@@ -87,6 +87,8 @@ p1 remote smoke <包目录> --ports 10000 # 上传→起容器→探针→
87
87
  p1 gen examples/demo-spec.json --out ./out --check # 从 spec 生成包并立即校验
88
88
  p1 gen examples/demo-db-spec.json --out ./out --check # 含面板数据库依赖与 init.sh 的示例
89
89
  p1 gen ./out/app/draft-spec.json --allow-unresolved # 明知缺证据也要生成(会记账)
90
+ p1 recipe list # 三条"成套写法":面板 DB / Redis / 两者
91
+ p1 recipe apply panel-postgres ./out/app/draft-spec.json --write # 一键接线
90
92
  p1 deploy ./out/p1-demo --param PANEL_APP_PORT_HTTP=15000 # 走面板 API 正式安装
91
93
  p1 fix <包目录> --check # 把 check 的结果幂等修掉
92
94
  p1 selftest # 验收:这个安装能不能用
@@ -105,7 +107,8 @@ p1 selftest # 验收:这个安装能不能用
105
107
  `values[].label` 必须是纯字符串、bool label 必须加引号。
106
108
 
107
109
  规则组 `C0xx` 管 compose:顶层 `version:`、image 引号、`${VAR}` 与表单 envKey
108
- 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突。
110
+ 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突、
111
+ `healthcheck` 必须是映射(`C005`,`healthcheck: true` 会让 Docker 直接解析失败)。
109
112
 
110
113
  规则组 `H0xx` 管文件卫生:LF/BOM、README store 风格、`.env.sample` 闭合。
111
114
 
@@ -175,6 +178,11 @@ README,抽取出端口、挂载点、环境变量、运行用户、额外运
175
178
  落进 draft spec——人工确认过的值必须能到达生成器,而不是停在报告里。产出三份文件:
176
179
  `decision-report.md`、`draft-spec.json`、`resolve.json`。
177
180
 
181
+ **多服务 compose 按服务归属**:compose 里每个服务在 draft spec 里各占一条,镜像、容器端口、
182
+ bind 挂载、环境变量、`depends_on`、运行时字段都只跟着自己的服务走。Dockerfile 里的变量归
183
+ 主服务;sidecar(自带的数据库/缓存)的密码不会被并到应用服务上,反过来也一样。多服务时还会给
184
+ 一条提示:如果面板上已经有对应的应用,考虑改用 `dependencies` 走面板托管(D10)。
185
+
178
186
  实测:`halo-dev/halo` 的挂载点在 Dockerfile 里没有 `VOLUME`,但 `HALO_WORK_DIR`
179
187
  默认值是 `/root/.halo2`;镜像只在 README 里出现——它把两条都标成 `inferred`
180
188
  并给出出处,而不是停下来或者瞎猜。
@@ -250,6 +258,9 @@ p1 fix <包目录> --check # 改完立刻重新校验
250
258
  | openviking | 1 fail + 6 warn | **0 fail + 1 warn** | 8 处 |
251
259
  | xiaozhi-esp32-server | 1 fail + 13 warn | **0 fail + 1 warn** | 14 处 |
252
260
 
261
+ (2026-09-20 在真实包上的实测记录;这些包随后已就地修复,
262
+ `xiaozhi-esp32-server-full` 现在是 `0 fail / 0 warn`。)
263
+
253
264
  静态校验和生成的 YAML 都不能告诉你"容器起不起得来、端口通不通、应用找不找得到配置文件"。
254
265
 
255
266
  ```bash
@@ -382,6 +393,24 @@ p1 gen ./out/app/draft-spec.json --allow-unresolved
382
393
  这两件事同样受证据约束:声明 `user` / `data_owner` 却拿不出 D07 证据、带了依赖而
383
394
  D10 说"未检测到"、带了脚本而 D09 无证据——都会在证据审计里被点名(lesson L004)。
384
395
 
396
+ ### 配方(recipes):把成套写法变成一条命令
397
+
398
+ ```bash
399
+ p1 resolve <仓库> --out ./out/app # 上游证据 + D10 线索
400
+ p1 recipe apply panel-postgres ./out/app/draft-spec.json --write # 一键接线
401
+ p1 gen ./out/app/draft-spec.json --check # 生成 + 校验
402
+ ```
403
+
404
+ 配方只填空的那部分是**结构**:面板变量怎么映射到应用变量、选择器什么形状、哪些变量得由
405
+ 包自己声明。应用侧的变量名一律来自 `resolve` 的 `dependency_hints`(或 `--bind` 显式给出),
406
+ 配方不发明变量名,也不替人决定要不要接依赖。
407
+
408
+ 三条内置配方:`panel-postgres`(PostgreSQL / MySQL / MariaDB,两步式选择器)、
409
+ `panel-redis`、`panel-db-and-cache`(复用前两条)。`p1 recipe show <id>` 展开角色表;
410
+ `p1 recipe apply` 默认只预览,加 `--write` 写回 spec;`--bind host=DB_HOST` 补 hints 没覆盖的
411
+ 角色。hints 的 `subjects` 会区分 `postgresql` 与 `redis` 这类**依赖主体**,所以
412
+ `REDIS_URL` 不会被绑到 Postgres 的连接串模板上。
413
+
385
414
  ## 当前状态
386
415
 
387
416
  完整流程已经闭环:
@@ -53,6 +53,8 @@ p1 remote smoke <包目录> --ports 10000 # 上传→起容器→探针→
53
53
  p1 gen examples/demo-spec.json --out ./out --check # 从 spec 生成包并立即校验
54
54
  p1 gen examples/demo-db-spec.json --out ./out --check # 含面板数据库依赖与 init.sh 的示例
55
55
  p1 gen ./out/app/draft-spec.json --allow-unresolved # 明知缺证据也要生成(会记账)
56
+ p1 recipe list # 三条"成套写法":面板 DB / Redis / 两者
57
+ p1 recipe apply panel-postgres ./out/app/draft-spec.json --write # 一键接线
56
58
  p1 deploy ./out/p1-demo --param PANEL_APP_PORT_HTTP=15000 # 走面板 API 正式安装
57
59
  p1 fix <包目录> --check # 把 check 的结果幂等修掉
58
60
  p1 selftest # 验收:这个安装能不能用
@@ -71,7 +73,8 @@ p1 selftest # 验收:这个安装能不能用
71
73
  `values[].label` 必须是纯字符串、bool label 必须加引号。
72
74
 
73
75
  规则组 `C0xx` 管 compose:顶层 `version:`、image 引号、`${VAR}` 与表单 envKey
74
- 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突。
76
+ 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突、
77
+ `healthcheck` 必须是映射(`C005`,`healthcheck: true` 会让 Docker 直接解析失败)。
75
78
 
76
79
  规则组 `H0xx` 管文件卫生:LF/BOM、README store 风格、`.env.sample` 闭合。
77
80
 
@@ -141,6 +144,11 @@ README,抽取出端口、挂载点、环境变量、运行用户、额外运
141
144
  落进 draft spec——人工确认过的值必须能到达生成器,而不是停在报告里。产出三份文件:
142
145
  `decision-report.md`、`draft-spec.json`、`resolve.json`。
143
146
 
147
+ **多服务 compose 按服务归属**:compose 里每个服务在 draft spec 里各占一条,镜像、容器端口、
148
+ bind 挂载、环境变量、`depends_on`、运行时字段都只跟着自己的服务走。Dockerfile 里的变量归
149
+ 主服务;sidecar(自带的数据库/缓存)的密码不会被并到应用服务上,反过来也一样。多服务时还会给
150
+ 一条提示:如果面板上已经有对应的应用,考虑改用 `dependencies` 走面板托管(D10)。
151
+
144
152
  实测:`halo-dev/halo` 的挂载点在 Dockerfile 里没有 `VOLUME`,但 `HALO_WORK_DIR`
145
153
  默认值是 `/root/.halo2`;镜像只在 README 里出现——它把两条都标成 `inferred`
146
154
  并给出出处,而不是停下来或者瞎猜。
@@ -216,6 +224,9 @@ p1 fix <包目录> --check # 改完立刻重新校验
216
224
  | openviking | 1 fail + 6 warn | **0 fail + 1 warn** | 8 处 |
217
225
  | xiaozhi-esp32-server | 1 fail + 13 warn | **0 fail + 1 warn** | 14 处 |
218
226
 
227
+ (2026-09-20 在真实包上的实测记录;这些包随后已就地修复,
228
+ `xiaozhi-esp32-server-full` 现在是 `0 fail / 0 warn`。)
229
+
219
230
  静态校验和生成的 YAML 都不能告诉你"容器起不起得来、端口通不通、应用找不找得到配置文件"。
220
231
 
221
232
  ```bash
@@ -348,6 +359,24 @@ p1 gen ./out/app/draft-spec.json --allow-unresolved
348
359
  这两件事同样受证据约束:声明 `user` / `data_owner` 却拿不出 D07 证据、带了依赖而
349
360
  D10 说"未检测到"、带了脚本而 D09 无证据——都会在证据审计里被点名(lesson L004)。
350
361
 
362
+ ### 配方(recipes):把成套写法变成一条命令
363
+
364
+ ```bash
365
+ p1 resolve <仓库> --out ./out/app # 上游证据 + D10 线索
366
+ p1 recipe apply panel-postgres ./out/app/draft-spec.json --write # 一键接线
367
+ p1 gen ./out/app/draft-spec.json --check # 生成 + 校验
368
+ ```
369
+
370
+ 配方只填空的那部分是**结构**:面板变量怎么映射到应用变量、选择器什么形状、哪些变量得由
371
+ 包自己声明。应用侧的变量名一律来自 `resolve` 的 `dependency_hints`(或 `--bind` 显式给出),
372
+ 配方不发明变量名,也不替人决定要不要接依赖。
373
+
374
+ 三条内置配方:`panel-postgres`(PostgreSQL / MySQL / MariaDB,两步式选择器)、
375
+ `panel-redis`、`panel-db-and-cache`(复用前两条)。`p1 recipe show <id>` 展开角色表;
376
+ `p1 recipe apply` 默认只预览,加 `--write` 写回 spec;`--bind host=DB_HOST` 补 hints 没覆盖的
377
+ 角色。hints 的 `subjects` 会区分 `postgresql` 与 `redis` 这类**依赖主体**,所以
378
+ `REDIS_URL` 不会被绑到 Postgres 的连接串模板上。
379
+
351
380
  ## 当前状态
352
381
 
353
382
  完整流程已经闭环:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "1panel-toolkit"
7
- version = "0.3.2"
7
+ version = "0.5.0"
8
8
  description = "Generate, check, fix and runtime-verify 1Panel app packages from a single spec."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -85,7 +85,9 @@ Install the CLI first: `uv tool install 1panel-toolkit` (or `pip install
85
85
  [{kind, kinds, style, env}]`. The generator renders the selector
86
86
  (`type: apps` + `child.type: service` by default) and maps `${PANEL_DB_*}`
87
87
  onto the application's own variable names, which *you* supply - it never
88
- invents them. `p1 resolve` lists candidates under `dependency_hints`.
88
+ invents them. `p1 resolve` lists candidates under `dependency_hints`, and
89
+ `p1 recipe apply panel-postgres <spec> --write` turns those hints into the
90
+ wiring in one step (`p1 recipe list` for the other recipes).
89
91
  * **D09, first-run config and permissions** - `config_files` ships the
90
92
  template the container reads, `data_owner` generates `init.sh` /
91
93
  `upgrade.sh` that create the mounted directories and hand them to the user
@@ -1,3 +1,3 @@
1
1
  """1panel-toolkit: one spec in, a verified 1Panel app package out."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.5.0"
@@ -59,6 +59,7 @@ def run(package: Package, report: Report, strict: bool = False) -> None:
59
59
  _check_services(version, report, name, generic)
60
60
  _check_ports(version, report, name)
61
61
  _check_networks(version, report, name)
62
+ _check_healthcheck(version, report, name)
62
63
 
63
64
 
64
65
  def _check_images(version: Version, report: Report, name: str, strict: bool) -> None:
@@ -186,3 +187,18 @@ def _check_networks(version: Version, report: Report, name: str) -> None:
186
187
  "can be reached through 1Panel's entry",
187
188
  name,
188
189
  )
190
+
191
+
192
+ def _check_healthcheck(version: Version, report: Report, name: str) -> None:
193
+ """`healthcheck:` must be a mapping; `healthcheck: true` breaks the parse."""
194
+ for service_name, service in version.services.items():
195
+ if not isinstance(service, dict) or "healthcheck" not in service:
196
+ continue
197
+ healthcheck = service["healthcheck"]
198
+ if not isinstance(healthcheck, dict):
199
+ report.fail(
200
+ "C005",
201
+ f"service {service_name}: healthcheck 必须是映射(test/interval/…),"
202
+ f"现在是 {type(healthcheck).__name__};Docker 解析不过",
203
+ name,
204
+ )
@@ -31,6 +31,7 @@ from .resolve import resolve as resolve_target
31
31
  from .resolve import write_outputs as write_resolve_outputs
32
32
  from .remote import RemoteError, SshTarget, check_host, smoke as run_smoke
33
33
  from .gen import GenResult, SpecError, generate as generate_package, load_spec
34
+ from . import recipes as recipes_module
34
35
  from .deploy import DeployError, deploy as run_deploy, remove_local, uninstall as run_uninstall
35
36
  from .selftest import run as run_selftest
36
37
  from .fix import fix as run_fix
@@ -494,6 +495,71 @@ def cmd_selftest(args: argparse.Namespace) -> int:
494
495
  return 0 if result.ok else 1
495
496
 
496
497
 
498
+ def cmd_recipe(args: argparse.Namespace) -> int:
499
+ import json as _json
500
+
501
+ if args.action == "list":
502
+ items = recipes_module.rules.recipes()
503
+ if args.json:
504
+ print(_json.dumps(items, ensure_ascii=False, indent=2))
505
+ return 0
506
+ width = max((len(str(item["id"])) for item in items), default=10)
507
+ for item in items:
508
+ matches = ",".join(item.get("matches") or [])
509
+ print(f"{item['id']:<{width}} {item.get('title')} [{matches}]")
510
+ return 0
511
+
512
+ if args.action == "show":
513
+ try:
514
+ print(recipes_module.describe(args.recipe_id))
515
+ except SpecError as exc:
516
+ print(f"error: {exc}", file=sys.stderr)
517
+ return 2
518
+ return 0
519
+
520
+ # apply
521
+ spec_path = Path(args.spec).resolve()
522
+ if not spec_path.exists():
523
+ print(f"error: 找不到 spec: {spec_path}", file=sys.stderr)
524
+ return 2
525
+ try:
526
+ spec = load_spec(spec_path)
527
+ updated, result = recipes_module.apply(
528
+ args.recipe_id, spec, binds=args.bind, service=args.service or ""
529
+ )
530
+ except SpecError as exc:
531
+ print(f"error: {exc}", file=sys.stderr)
532
+ return 2
533
+
534
+ if args.json:
535
+ print(
536
+ _json.dumps(
537
+ {
538
+ "recipe": result.recipe.get("id"),
539
+ "dependencies": result.dependencies,
540
+ "bound": result.bound,
541
+ "missing": result.missing,
542
+ "warnings": result.warnings,
543
+ },
544
+ ensure_ascii=False,
545
+ indent=2,
546
+ )
547
+ )
548
+ else:
549
+ print(result.render())
550
+ if args.write:
551
+ spec_path.write_text(
552
+ _json.dumps(updated, ensure_ascii=False, indent=2) + "\n",
553
+ encoding="utf-8",
554
+ newline="\n",
555
+ )
556
+ print(f"\n已写入 {spec_path}")
557
+ print("下一步:`p1 gen <spec> --check`")
558
+ elif not args.json:
559
+ print("\n(预览;加 --write 写回 spec)")
560
+ return 0
561
+
562
+
497
563
  def cmd_fix(args: argparse.Namespace) -> int:
498
564
  target = Path(args.path).resolve()
499
565
  if not target.is_dir():
@@ -733,6 +799,32 @@ def build_parser() -> argparse.ArgumentParser:
733
799
  pat_export.set_defaults(action="export", func=cmd_patterns)
734
800
  pat.set_defaults(action="list", func=cmd_patterns)
735
801
 
802
+ recipe = sub.add_parser(
803
+ "recipe", help="apply a packaged way of wiring panel-managed dependencies"
804
+ )
805
+ recipe_sub = recipe.add_subparsers(dest="action")
806
+ recipe_list = recipe_sub.add_parser("list", help="list recipes")
807
+ recipe_list.add_argument("--json", action="store_true")
808
+ recipe_list.set_defaults(action="list", func=cmd_recipe)
809
+ recipe_show = recipe_sub.add_parser("show", help="explain one recipe")
810
+ recipe_show.add_argument("recipe_id")
811
+ recipe_show.set_defaults(action="show", func=cmd_recipe)
812
+ recipe_apply = recipe_sub.add_parser(
813
+ "apply", help="merge a recipe's dependency wiring into a spec"
814
+ )
815
+ recipe_apply.add_argument("recipe_id")
816
+ recipe_apply.add_argument("spec", help="spec JSON (a resolve draft works)")
817
+ recipe_apply.add_argument(
818
+ "--bind",
819
+ action="append",
820
+ help="role=ENV_NAME for a role the hints do not cover, e.g. --bind host=DB_HOST",
821
+ )
822
+ recipe_apply.add_argument("--service", help="which spec service to wire (default: the first)")
823
+ recipe_apply.add_argument("--write", action="store_true", help="write the spec back")
824
+ recipe_apply.add_argument("--json", action="store_true")
825
+ recipe_apply.set_defaults(action="apply", func=cmd_recipe)
826
+ recipe.set_defaults(action="list", func=cmd_recipe)
827
+
736
828
  depth = sub.add_parser(
737
829
  "depth", help="measure how app-specific a package is (template vs adapted)"
738
830
  )
@@ -208,6 +208,9 @@ def build_fields(
208
208
  dependency wiring (which is rendered literally, not as a default).
209
209
  """
210
210
  fields: list[dict] = []
211
+ # Ports first: with more than one service the port fields would otherwise be
212
+ # scattered between another service's variables.
213
+ port_fields: list[dict] = []
211
214
  hidden: dict[str, list[str]] = {}
212
215
  # Keys the generator adds on its own; they must not be double counted as
213
216
  # "hidden upstream variables" when the upstream compose also sets them.
@@ -219,7 +222,7 @@ def build_fields(
219
222
  port = service.get("container_port")
220
223
  if port:
221
224
  env_key = "PANEL_APP_PORT_HTTP" if port_index == 0 else f"PANEL_APP_PORT_{port_index}"
222
- fields.append(
225
+ port_fields.append(
223
226
  {
224
227
  "envKey": env_key,
225
228
  "type": "number",
@@ -252,6 +255,7 @@ def build_fields(
252
255
  entry["description"] = item["description"]
253
256
  fields.append(entry)
254
257
 
258
+ fields = port_fields + fields
255
259
  hidden = {
256
260
  service: [key for key in keys if key not in reserved]
257
261
  for service, keys in hidden.items()
@@ -370,7 +374,6 @@ def build_compose(
370
374
  if env_lines:
371
375
  entry["environment"] = env_lines
372
376
  for extra in (
373
- "healthcheck",
374
377
  "security_opt",
375
378
  "cap_add",
376
379
  "cap_drop",
@@ -392,6 +395,16 @@ def build_compose(
392
395
  ):
393
396
  if service.get(extra):
394
397
  entry[extra] = service[extra]
398
+ # `healthcheck` is a mapping, never a flag: a boolean from a spec (or an
399
+ # evidence flag) must not reach the compose file, Docker rejects it.
400
+ healthcheck = service.get("healthcheck")
401
+ if isinstance(healthcheck, dict):
402
+ entry["healthcheck"] = healthcheck
403
+ elif healthcheck:
404
+ warnings.append(
405
+ f"{name}: healthcheck 不是映射({healthcheck!r}),已忽略;"
406
+ "探针要写成 {test: [...], interval: 10s, ...}"
407
+ )
395
408
  entry["labels"] = {"createdBy": "Apps"}
396
409
  services[name] = entry
397
410