1panel-toolkit 0.3.0__py3-none-any.whl

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 (47) hide show
  1. 1panel_toolkit-0.3.0.dist-info/METADATA +407 -0
  2. 1panel_toolkit-0.3.0.dist-info/RECORD +47 -0
  3. 1panel_toolkit-0.3.0.dist-info/WHEEL +4 -0
  4. 1panel_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  5. 1panel_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. p1toolkit/__init__.py +3 -0
  7. p1toolkit/checks/__init__.py +21 -0
  8. p1toolkit/checks/compose.py +188 -0
  9. p1toolkit/checks/forms.py +129 -0
  10. p1toolkit/checks/hygiene.py +73 -0
  11. p1toolkit/checks/i18n.py +140 -0
  12. p1toolkit/checks/lessons.py +111 -0
  13. p1toolkit/checks/structure.py +140 -0
  14. p1toolkit/checks/substance.py +55 -0
  15. p1toolkit/cli.py +890 -0
  16. p1toolkit/config.py +114 -0
  17. p1toolkit/dependencies.py +218 -0
  18. p1toolkit/deploy.py +302 -0
  19. p1toolkit/depth.py +414 -0
  20. p1toolkit/errors.py +18 -0
  21. p1toolkit/evidence.py +523 -0
  22. p1toolkit/fix.py +394 -0
  23. p1toolkit/gen.py +728 -0
  24. p1toolkit/lifecycle.py +143 -0
  25. p1toolkit/loader.py +110 -0
  26. p1toolkit/logo.py +174 -0
  27. p1toolkit/model.py +71 -0
  28. p1toolkit/net.py +81 -0
  29. p1toolkit/panel.py +154 -0
  30. p1toolkit/patterns/__init__.py +250 -0
  31. p1toolkit/patterns/catalog.py +271 -0
  32. p1toolkit/patterns/data/patterns.json +724 -0
  33. p1toolkit/patterns/extract.py +402 -0
  34. p1toolkit/remote.py +554 -0
  35. p1toolkit/report.py +96 -0
  36. p1toolkit/resolve/__init__.py +554 -0
  37. p1toolkit/resolve/analyze.py +329 -0
  38. p1toolkit/resolve/sources.py +306 -0
  39. p1toolkit/rules/__init__.py +99 -0
  40. p1toolkit/rules/data/decisions.yaml +132 -0
  41. p1toolkit/rules/data/dependencies.yaml +113 -0
  42. p1toolkit/rules/data/envkeys.yaml +19 -0
  43. p1toolkit/rules/data/form-design.yaml +59 -0
  44. p1toolkit/rules/data/i18n.yaml +354 -0
  45. p1toolkit/rules/data/lessons.yaml +84 -0
  46. p1toolkit/rules/data/store.yaml +171 -0
  47. p1toolkit/selftest.py +211 -0
@@ -0,0 +1,188 @@
1
+ """docker-compose.yml shape and its closure against the install form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Iterator
7
+
8
+ from .. import rules
9
+ from ..model import Package, Version
10
+ from ..report import Report
11
+
12
+ # Deliberately does not require a closing brace: `${A:-${B}}` is legal Compose,
13
+ # and a strict pattern would swallow the inner reference.
14
+ ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)([^}$]*)")
15
+ IMAGE_LINE = re.compile(r"^\s*image:\s*(.+?)\s*$")
16
+ PORT_LINE = re.compile(r"^\s*-\s*[\"']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}([^\"']*)[\"']?:")
17
+
18
+
19
+ def env_refs(value: Any) -> Iterator[tuple[str, str]]:
20
+ """Yield ``(name, trailing)`` for every ``${NAME...}`` in a structure."""
21
+ if isinstance(value, str):
22
+ for match in ENV_REF.finditer(value):
23
+ yield match.group(1), match.group(2)
24
+ elif isinstance(value, dict):
25
+ for item in value.values():
26
+ yield from env_refs(item)
27
+ elif isinstance(value, list):
28
+ for item in value:
29
+ yield from env_refs(item)
30
+
31
+
32
+ def declared_envkeys(version: Version) -> set[str]:
33
+ keys: set[str] = set()
34
+ for field in version.form_fields:
35
+ if field.get("envKey"):
36
+ keys.add(str(field["envKey"]))
37
+ child = field.get("child")
38
+ if isinstance(child, dict) and child.get("envKey"):
39
+ keys.add(str(child["envKey"]))
40
+ return keys
41
+
42
+
43
+ def run(package: Package, report: Report, strict: bool = False) -> None:
44
+ store = rules.store()
45
+ implicit = rules.implicit_envkeys()
46
+ generic = set(store.get("generic_service_names") or [])
47
+ for version in package.versions:
48
+ if not version.compose:
49
+ continue
50
+ name = f"{version.name}/docker-compose.yml"
51
+ if store.get("compose_forbids_top_level_version") and "version" in version.compose:
52
+ report.fail(
53
+ "C001",
54
+ "top-level version: is deprecated in 1Panel compose files; remove it",
55
+ name,
56
+ )
57
+ _check_images(version, report, name, strict)
58
+ _check_env_closure(version, report, name, implicit)
59
+ _check_services(version, report, name, generic)
60
+ _check_ports(version, report, name)
61
+ _check_networks(version, report, name)
62
+
63
+
64
+ def _check_images(version: Version, report: Report, name: str, strict: bool) -> None:
65
+ for number, line in enumerate(version.compose_text.splitlines(), start=1):
66
+ match = IMAGE_LINE.match(line)
67
+ if not match:
68
+ continue
69
+ value = match.group(1)
70
+ if not (value.startswith('"') and value.endswith('"')) and not (
71
+ value.startswith("'") and value.endswith("'")
72
+ ):
73
+ text = "image should be double-quoted to avoid YAML/digest parsing edge cases"
74
+ (report.fail if strict else report.warn)("C002", text, name, number)
75
+
76
+
77
+ def _check_env_closure(
78
+ version: Version, report: Report, name: str, implicit: set[str]
79
+ ) -> None:
80
+ declared = declared_envkeys(version)
81
+ seen: dict[str, str] = {}
82
+ for ref, trailing in env_refs(version.compose):
83
+ seen.setdefault(ref, trailing)
84
+ for ref, trailing in sorted(seen.items()):
85
+ if ref in declared or ref in implicit:
86
+ continue
87
+ if trailing.startswith(":-"):
88
+ # Deliberate: the generator keeps non-decision variables out of the
89
+ # install form and gives them a working default in the compose.
90
+ report.info(
91
+ "C003",
92
+ f"${{{ref}}} has no matching formFields envKey (a compose default is present)",
93
+ name,
94
+ )
95
+ else:
96
+ report.fail(
97
+ "C004",
98
+ f"${{{ref}}} has no matching formFields envKey and no compose default",
99
+ name,
100
+ )
101
+
102
+
103
+ def _check_services(
104
+ version: Version, report: Report, name: str, generic: set[str]
105
+ ) -> None:
106
+ expect_label = rules.store().get("service_label_created_by")
107
+ for service_name, service in version.services.items():
108
+ if not isinstance(service, dict):
109
+ continue
110
+ if service.get("container_name") != "${CONTAINER_NAME}" and not str(
111
+ service.get("container_name") or ""
112
+ ).startswith("${CONTAINER_NAME}"):
113
+ report.warn(
114
+ "C010",
115
+ f"service {service_name}: container_name should be ${{CONTAINER_NAME}}"
116
+ " (or ${{CONTAINER_NAME}}-<suffix>)",
117
+ name,
118
+ )
119
+ labels = service.get("labels") or {}
120
+ if isinstance(labels, list):
121
+ labels = dict(
122
+ item.split("=", 1) for item in labels if isinstance(item, str) and "=" in item
123
+ )
124
+ if expect_label and str(labels.get("createdBy")) != str(expect_label):
125
+ report.warn(
126
+ "C011",
127
+ f'service {service_name}: missing labels.createdBy: "{expect_label}"',
128
+ name,
129
+ )
130
+ networks = service.get("networks") or []
131
+ if isinstance(networks, dict):
132
+ networks = list(networks)
133
+ if service_name in generic and len(networks) > 1:
134
+ report.warn(
135
+ "C012",
136
+ f"service {service_name}: generic name on a shared network can shadow "
137
+ "a same-named service; use an app-prefixed name or an alias",
138
+ name,
139
+ )
140
+
141
+
142
+ def _check_ports(version: Version, report: Report, name: str) -> None:
143
+ prefix = str(rules.store().get("port_envkey_prefix"))
144
+ for service_name, service in version.services.items():
145
+ if not isinstance(service, dict):
146
+ continue
147
+ for mapping in service.get("ports") or []:
148
+ if not isinstance(mapping, str) or not mapping.startswith("${"):
149
+ continue
150
+ match = PORT_LINE.match(f" - {mapping}:")
151
+ if not match:
152
+ continue
153
+ env_key = match.group(1)
154
+ if not env_key.startswith(prefix):
155
+ report.warn(
156
+ "C020",
157
+ f"service {service_name}: published port uses {env_key}; 1Panel only "
158
+ f"tracks occupancy for {prefix}* envKeys",
159
+ name,
160
+ )
161
+
162
+
163
+ def _check_networks(version: Version, report: Report, name: str) -> None:
164
+ networks = version.compose.get("networks") or {}
165
+ external = False
166
+ if isinstance(networks, dict):
167
+ for config in networks.values():
168
+ if isinstance(config, dict) and config.get("external"):
169
+ external = True
170
+ elif config is None:
171
+ continue
172
+ published = any(
173
+ isinstance(service, dict) and service.get("ports")
174
+ for service in version.services.values()
175
+ )
176
+ # `version.services.get("network_mode")` would look up a *service named*
177
+ # "network_mode"; the question is whether any service declares it.
178
+ uses_host_network = any(
179
+ isinstance(service, dict) and service.get("network_mode")
180
+ for service in version.services.values()
181
+ )
182
+ if published and not external and not uses_host_network:
183
+ report.warn(
184
+ "C030",
185
+ "no external network declared; a bridge-type app normally joins one so it "
186
+ "can be reached through 1Panel's entry",
187
+ name,
188
+ )
@@ -0,0 +1,129 @@
1
+ """Install-form design: keep the form a decision list, not a config dump."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ from .. import rules
8
+ from ..model import Package
9
+ from ..report import Report
10
+
11
+
12
+ def _budget() -> dict:
13
+ return rules.load("form-design").get("budget") or {}
14
+
15
+
16
+ def is_secret(env_key: str) -> bool:
17
+ fragments = rules.load("form-design").get("secret_fragments") or []
18
+ upper = env_key.upper()
19
+ return any(fragment in upper for fragment in fragments)
20
+
21
+
22
+ def _prefix(env_key: str) -> str:
23
+ return env_key.rsplit("_", 1)[0] if "_" in env_key else env_key
24
+
25
+
26
+ def run(package: Package, report: Report, strict: bool = False) -> None:
27
+ budget = _budget()
28
+ warn_at = int(budget.get("fields_warn") or 11)
29
+ fail_at = int(budget.get("fields_fail") or 20)
30
+ min_ratio = float(budget.get("min_required_ratio") or 0.6)
31
+ max_per_prefix = int(budget.get("max_fields_per_prefix") or 5)
32
+
33
+ for version in package.versions:
34
+ fields = version.form_fields
35
+ if not fields:
36
+ continue
37
+ label = f"{version.name}/data.yml"
38
+ total = len(fields)
39
+ required = [f for f in fields if f.get("required")]
40
+ ratio = len(required) / total
41
+
42
+ if total > fail_at:
43
+ report.fail(
44
+ "F001",
45
+ f"安装表单有 {total} 个字段(官方中位数 {budget.get('fields_median_official')},"
46
+ f"上限 {fail_at})。请把上游变量写进 compose 默认值,只暴露用户必须决定的项",
47
+ label,
48
+ )
49
+ elif total > warn_at:
50
+ report.warn(
51
+ "F001",
52
+ f"安装表单有 {total} 个字段,超过官方 p90({warn_at})。"
53
+ "确认每一项都是用户必须决定的,否则收进默认值",
54
+ label,
55
+ )
56
+
57
+ if total >= 6 and ratio < min_ratio:
58
+ report.warn(
59
+ "F002",
60
+ f"只有 {len(required)}/{total} 个字段标为必填({ratio:.0%},官方均值 88%);"
61
+ "必填/可选混在一起会让用户不知道该填哪个",
62
+ label,
63
+ )
64
+
65
+ for field in fields:
66
+ env_key = str(field.get("envKey") or "")
67
+ default = str(field.get("default") or "").strip()
68
+ secret = is_secret(env_key)
69
+ if field.get("required") and not default and not secret:
70
+ report.warn(
71
+ "F003",
72
+ f"{env_key} 标了必填却没有默认值,而且不像密钥字段;"
73
+ "要么给默认值,要么降为可选",
74
+ label,
75
+ )
76
+ if not default and not secret and field.get("type") != "password":
77
+ report.warn(
78
+ "F004",
79
+ f"{env_key} 默认值为空;非密钥字段应当有可用默认值,"
80
+ "否则用户会卡在一个他无从判断的输入上",
81
+ label,
82
+ )
83
+ if secret and field.get("type") != "password":
84
+ report.warn(
85
+ "F005",
86
+ f"{env_key} 看起来是密钥,但 type 是 {field.get('type')!r};"
87
+ "密钥字段应为 password",
88
+ label,
89
+ )
90
+
91
+ clusters = Counter(
92
+ _prefix(str(f.get("envKey") or "")) for f in fields if f.get("envKey")
93
+ )
94
+ for prefix, count in clusters.most_common():
95
+ if count > max_per_prefix:
96
+ report.warn(
97
+ "F006",
98
+ f"{prefix}_* 有 {count} 个字段(官方仅 27/1617 个版本超过 5 个);"
99
+ "这类「提供商矩阵」应折叠成一个 select + 默认值",
100
+ label,
101
+ )
102
+
103
+ _check_fields_are_wired(version, report, label)
104
+
105
+
106
+ # Panel-provided variables are consumed by the platform, not by the compose text.
107
+ _PLATFORM_PREFIXES = ("PANEL_DB_", "PANEL_REDIS_", "REDIS_", "PANEL_APP_PORT_")
108
+
109
+
110
+ def _check_fields_are_wired(version, report: Report, label: str) -> None:
111
+ """A form field nobody reads is a box the user fills in for nothing."""
112
+ haystack = version.compose_text
113
+ for name, path in version.files.items():
114
+ if name.startswith("scripts/") or name.endswith((".sh", ".conf", ".yaml", ".json")):
115
+ try:
116
+ haystack += "\n" + path.read_text(encoding="utf-8-sig", errors="replace")
117
+ except OSError:
118
+ continue
119
+ for field in version.form_fields:
120
+ env_key = str(field.get("envKey") or "")
121
+ if not env_key or env_key.startswith(_PLATFORM_PREFIXES):
122
+ continue
123
+ if env_key not in haystack:
124
+ report.warn(
125
+ "F007",
126
+ f"{env_key} 出现在安装表单里,但 compose 和脚本里都没有引用它;"
127
+ "填了也不会生效,要么接线要么移除",
128
+ label,
129
+ )
@@ -0,0 +1,73 @@
1
+ """File hygiene: line endings, README shape, .env.sample closure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..loader import line_ending_issues
6
+ from ..model import Package
7
+ from ..report import Report
8
+ from .compose import env_refs
9
+
10
+
11
+ def run(package: Package, report: Report, strict: bool = False) -> None:
12
+ _check_text_files(package, report)
13
+ _check_readme(package, report)
14
+ _check_env_sample(package, report)
15
+
16
+
17
+ def _check_text_files(package: Package, report: Report) -> None:
18
+ for rel, path in package.files.items():
19
+ problem = line_ending_issues(path)
20
+ if problem:
21
+ report.warn(
22
+ "H001",
23
+ f"{problem}; store files should be LF without BOM",
24
+ rel,
25
+ )
26
+
27
+
28
+ def _check_readme(package: Package, report: Report) -> None:
29
+ readme = package.root / "README.md"
30
+ if not readme.exists():
31
+ report.warn("H010", "README.md is missing", "README.md")
32
+ return
33
+ text = readme.read_text(encoding="utf-8-sig", errors="replace")
34
+ if "## 产品介绍" not in text:
35
+ report.warn(
36
+ "H011", "README.md should follow the store style with a 产品介绍 section", "README.md"
37
+ )
38
+ if "## 主要功能" not in text and "## 访问说明" not in text:
39
+ report.info(
40
+ "H012",
41
+ "README.md has neither 主要功能 nor 访问说明; store READMEs usually include one",
42
+ "README.md",
43
+ )
44
+
45
+
46
+ def _check_env_sample(package: Package, report: Report) -> None:
47
+ for version in package.versions:
48
+ if not version.compose:
49
+ continue
50
+ referenced = {name for name, _ in env_refs(version.compose)}
51
+ if not referenced:
52
+ continue
53
+ sample = version.env_sample
54
+ if not sample:
55
+ report.warn(
56
+ "H020",
57
+ f"{version.name}/.env.sample is missing; standalone compose users need it",
58
+ f"{version.name}/.env.sample",
59
+ )
60
+ continue
61
+ present = {
62
+ line.split("=", 1)[0].strip()
63
+ for line in sample.splitlines()
64
+ if "=" in line and not line.lstrip().startswith("#")
65
+ }
66
+ missing = sorted(referenced - present)
67
+ if missing:
68
+ report.warn(
69
+ "H021",
70
+ f"{version.name}/.env.sample does not list: {missing}",
71
+ f"{version.name}/.env.sample",
72
+ )
73
+
@@ -0,0 +1,140 @@
1
+ """Translation coverage and label shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from .. import rules
9
+ from ..model import Package
10
+ from ..report import Report
11
+
12
+ _BOOL_LABEL = re.compile(r"^\s*(?:-\s*)?label:\s*(true|false)\s*$", re.IGNORECASE)
13
+ _LABEL_LINE = re.compile(r"^\s*(?:-\s*)?label:\s*(\S+)")
14
+
15
+
16
+ def _missing(data: Any, languages: list[str]) -> list[str]:
17
+ if not isinstance(data, dict):
18
+ return list(languages)
19
+ return [lang for lang in languages if not str(data.get(lang) or "").strip()]
20
+
21
+
22
+ def run(package: Package, report: Report, strict: bool = False) -> None:
23
+ required = rules.required_languages()
24
+ all_langs = rules.all_languages()
25
+ additional = package.additional
26
+
27
+ top_description = package.app.get("description")
28
+ if isinstance(top_description, dict):
29
+ report.fail(
30
+ "I001",
31
+ "root data.yml description must be a single-line string, not a language map",
32
+ "data.yml",
33
+ )
34
+
35
+ description = additional.get("description")
36
+ if description is None:
37
+ report.warn("I002", "additionalProperties.description is missing", "data.yml")
38
+ else:
39
+ missing = _missing(description, required)
40
+ if missing:
41
+ report.fail(
42
+ "I003",
43
+ f"additionalProperties.description is missing languages: {missing}",
44
+ "data.yml",
45
+ )
46
+ if isinstance(description, dict):
47
+ absent = [lang for lang in all_langs if lang not in description]
48
+ if absent:
49
+ report.warn(
50
+ "I004",
51
+ f"description does not use the store's full language set; missing {absent}",
52
+ "data.yml",
53
+ )
54
+
55
+ title = package.app.get("title")
56
+ short_zh = additional.get("shortDescZh")
57
+ if title and short_zh:
58
+ # title and shortDescZh are both the store's one-liner slot and must
59
+ # agree; 741/771 official apps do. The top-level description is allowed
60
+ # to be a longer sentence — about 4% of shipped apps differ there — so
61
+ # that mismatch is reported without blocking.
62
+ if str(title).strip() != str(short_zh).strip():
63
+ report.fail(
64
+ "I005",
65
+ "root title and additionalProperties.shortDescZh must be the same "
66
+ f"short text; got title={title!r} shortDescZh={short_zh!r}",
67
+ "data.yml",
68
+ )
69
+ elif top_description and str(top_description).strip() != str(short_zh).strip():
70
+ report.warn(
71
+ "I006",
72
+ "top-level description differs from shortDescZh; the store mostly "
73
+ f"repeats the same sentence (got description={top_description!r})",
74
+ "data.yml",
75
+ )
76
+
77
+ for version in package.versions:
78
+ label = f"{version.name}/data.yml"
79
+ for field in version.form_fields:
80
+ env_key = field.get("envKey")
81
+ label_map = field.get("label")
82
+ if isinstance(label_map, dict):
83
+ missing = _missing(label_map, required)
84
+ if missing:
85
+ report.warn(
86
+ "I010",
87
+ f"{env_key}: label is missing languages {missing}",
88
+ label,
89
+ )
90
+ elif field.get("labelZh") or field.get("labelEn"):
91
+ report.warn(
92
+ "I011",
93
+ f"{env_key}: only labelZh/labelEn present; add the full label map",
94
+ label,
95
+ )
96
+ else:
97
+ report.warn(
98
+ "I012", f"{env_key}: no label or labelZh/labelEn", label
99
+ )
100
+ _check_values(env_key, field, report, label)
101
+ _check_bool_labels(version.files.get("data.yml"), report)
102
+
103
+
104
+ def _check_values(env_key: Any, field: dict[str, Any], report: Report, label: str) -> None:
105
+ values = field.get("values")
106
+ if field.get("type") in {"select", "apps"} and not values:
107
+ report.fail(
108
+ "I013", f"{env_key}: type {field.get('type')!r} requires values", label
109
+ )
110
+ for item in values or []:
111
+ if not isinstance(item, dict):
112
+ continue
113
+ item_label = item.get("label")
114
+ if isinstance(item_label, dict):
115
+ report.fail(
116
+ "I014",
117
+ f"{env_key}: values[].label must be a plain string, not a language map "
118
+ "(1Panel fails to unmarshal it)",
119
+ label,
120
+ )
121
+ if item_label is None or item.get("value") is None:
122
+ report.fail(
123
+ "I015", f"{env_key}: each values[] entry needs both label and value", label
124
+ )
125
+
126
+
127
+ def _check_bool_labels(path, report: Report) -> None:
128
+ """YAML would parse an unquoted `label: true` as a bool; catch it in the raw text."""
129
+ if path is None or not path.exists():
130
+ return
131
+ for number, line in enumerate(
132
+ path.read_text(encoding="utf-8-sig", errors="replace").splitlines(), start=1
133
+ ):
134
+ if _BOOL_LABEL.match(line):
135
+ report.fail(
136
+ "I016",
137
+ "values[].label must be quoted when it is true/false",
138
+ path,
139
+ number,
140
+ )
@@ -0,0 +1,111 @@
1
+ """Pre-flight detectors derived from real packaging failures.
2
+
3
+ Each detector corresponds to an entry in ``rules/data/lessons.yaml``. They are
4
+ deliberately conservative: a false positive here costs a reviewer two minutes,
5
+ a missed one costs a broken install.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from .. import rules
14
+ from ..model import Package, Version
15
+ from ..report import Report
16
+
17
+ _IGNORED_ARTIFACTS = {".gitkeep", ".keep", ".DS_Store"}
18
+
19
+
20
+ def _lesson(lesson_id: str) -> dict:
21
+ for entry in rules.lessons():
22
+ if entry.get("id") == lesson_id:
23
+ return entry
24
+ return {}
25
+
26
+
27
+ def run(package: Package, report: Report, strict: bool = False) -> None:
28
+ for version in package.versions:
29
+ if not version.compose:
30
+ continue
31
+ _first_run_config_missing(package, version, report)
32
+ _dependency_without_healthcheck(version, report)
33
+
34
+
35
+ def _bind_host_paths(version: Version) -> list[str]:
36
+ hosts: list[str] = []
37
+ for service in version.services.values():
38
+ if not isinstance(service, dict):
39
+ continue
40
+ for volume in service.get("volumes") or []:
41
+ if not isinstance(volume, str) or ":" not in volume:
42
+ continue
43
+ host = volume.split(":", 1)[0].strip()
44
+ if host.startswith("./") or host.startswith("../"):
45
+ hosts.append(host)
46
+ return hosts
47
+
48
+
49
+ def _default_of(version: Version, env_key: str) -> str | None:
50
+ for field in version.form_fields:
51
+ if field.get("envKey") == env_key:
52
+ value = field.get("default")
53
+ return str(value) if value is not None else None
54
+ return None
55
+
56
+
57
+ def _resolve_host(version: Version, host: str) -> Path | None:
58
+ """Resolve a bind host path, substituting form defaults for ${VAR}."""
59
+ def substitute(match: re.Match[str]) -> str:
60
+ return _default_of(version, match.group(1)) or ""
61
+
62
+ resolved = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}", substitute, host)
63
+ if not resolved or "${" in resolved:
64
+ return None
65
+ return (version.dir / resolved).resolve()
66
+
67
+
68
+ def _first_run_config_missing(package: Package, version: Version, report: Report) -> None:
69
+ hosts = _bind_host_paths(version)
70
+ if not hosts:
71
+ return
72
+ # version.files keys are relative to the version directory
73
+ has_init = "scripts/init.sh" in version.files
74
+ if has_init:
75
+ return
76
+ shipped = 0
77
+ for host in hosts:
78
+ target = _resolve_host(version, host)
79
+ if target is None or not target.is_dir():
80
+ continue
81
+ if any(
82
+ child.name not in _IGNORED_ARTIFACTS for child in target.rglob("*") if child.is_file()
83
+ ):
84
+ shipped += 1
85
+ if shipped:
86
+ return
87
+ lesson = _lesson("L001")
88
+ report.warn(
89
+ "L001",
90
+ f"{lesson.get('title', 'first-run config may be missing')} — "
91
+ f"{version.name}/ mounts {sorted(set(hosts))} with no files and no scripts/init.sh; "
92
+ "if the app needs a config file there it will crash-loop on first start",
93
+ f"{version.name}/docker-compose.yml",
94
+ )
95
+
96
+
97
+ def _dependency_without_healthcheck(version: Version, report: Report) -> None:
98
+ for service_name, service in version.services.items():
99
+ if not isinstance(service, dict):
100
+ continue
101
+ depends = service.get("depends_on") or {}
102
+ names = list(depends) if isinstance(depends, dict) else list(depends)
103
+ for dependency in names:
104
+ target = version.services.get(dependency)
105
+ if isinstance(target, dict) and not target.get("healthcheck"):
106
+ report.info(
107
+ "L002",
108
+ f"{service_name} depends_on {dependency}, which has no healthcheck; "
109
+ "compose only orders container start, not readiness",
110
+ f"{version.name}/docker-compose.yml",
111
+ )