devcake-cli 0.1.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.
- devcake_cli/__init__.py +10 -0
- devcake_cli/__main__.py +8 -0
- devcake_cli/baker.py +15 -0
- devcake_cli/doctor.py +600 -0
- devcake_cli/down.py +37 -0
- devcake_cli/envfile.py +199 -0
- devcake_cli/main.py +209 -0
- devcake_cli/paths.py +29 -0
- devcake_cli/setup.py +933 -0
- devcake_cli/status.py +75 -0
- devcake_cli/up.py +676 -0
- devcake_cli-0.1.0.dist-info/METADATA +375 -0
- devcake_cli-0.1.0.dist-info/RECORD +17 -0
- devcake_cli-0.1.0.dist-info/WHEEL +5 -0
- devcake_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devcake_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- devcake_cli-0.1.0.dist-info/top_level.txt +1 -0
devcake_cli/envfile.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""``.env`` upsert, permission floor, and bootstrap auto-init (ADR-0038 Decision 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import string
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Keys app boot refuses when empty/weak (api/main._refuse_insecure_passwords)
|
|
12
|
+
# plus OO_INGEST_EMAIL (blank email → silent 401s).
|
|
13
|
+
REQUIRED_BOOTSTRAP_KEYS: tuple[str, ...] = (
|
|
14
|
+
"ADMIN_PASSWORD",
|
|
15
|
+
"REDIS_PASSWORD",
|
|
16
|
+
"DAGU_PASSWORD",
|
|
17
|
+
"OO_ROOT_PASSWORD",
|
|
18
|
+
"OO_INGEST_PASSWORD",
|
|
19
|
+
"GITEA_ADMIN_PASSWORD",
|
|
20
|
+
"OO_INGEST_EMAIL",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_WEAK = frozenset(
|
|
24
|
+
{"", "change-me", "change-me-too", "change-me-as-well", "password", "admin"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
31
|
+
"""Parse KEY=VALUE lines; comments and blanks ignored. Last wins."""
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return {}
|
|
34
|
+
out: dict[str, str] = {}
|
|
35
|
+
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
36
|
+
line = raw.strip()
|
|
37
|
+
if not line or line.startswith("#"):
|
|
38
|
+
continue
|
|
39
|
+
m = _KEY_LINE.match(raw) # keep raw (no strip) so values preserve spaces
|
|
40
|
+
if not m:
|
|
41
|
+
# try stripped form for indented oddities
|
|
42
|
+
m = _KEY_LINE.match(line)
|
|
43
|
+
if not m:
|
|
44
|
+
continue
|
|
45
|
+
out[m.group(1)] = m.group(2)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_env_lines(path: Path) -> list[str]:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return []
|
|
52
|
+
return path.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def upsert_env_var(key: str, value: str, path: Path) -> None:
|
|
56
|
+
"""Atomic same-filesystem upsert (mirrors devcake up OPS-L6 sibling-temp rename)."""
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
lines = read_env_lines(path) if path.is_file() else []
|
|
59
|
+
replaced = False
|
|
60
|
+
new_lines: list[str] = []
|
|
61
|
+
for line in lines:
|
|
62
|
+
raw = line.rstrip("\n\r")
|
|
63
|
+
if raw.startswith(f"{key}="):
|
|
64
|
+
new_lines.append(f"{key}={value}\n")
|
|
65
|
+
replaced = True
|
|
66
|
+
else:
|
|
67
|
+
if line.endswith("\n") or line.endswith("\r\n"):
|
|
68
|
+
new_lines.append(line if line.endswith("\n") else line + "\n")
|
|
69
|
+
else:
|
|
70
|
+
new_lines.append(line + "\n")
|
|
71
|
+
if not replaced:
|
|
72
|
+
if new_lines and not new_lines[-1].endswith("\n"):
|
|
73
|
+
new_lines[-1] = new_lines[-1] + "\n"
|
|
74
|
+
if new_lines and new_lines[-1].strip():
|
|
75
|
+
new_lines.append("\n")
|
|
76
|
+
new_lines.append(f"{key}={value}\n")
|
|
77
|
+
|
|
78
|
+
tmp = path.with_name(f".env.tmp.{secrets.token_hex(4)}")
|
|
79
|
+
tmp.write_text("".join(new_lines), encoding="utf-8")
|
|
80
|
+
if path.is_file():
|
|
81
|
+
try:
|
|
82
|
+
mode = path.stat().st_mode & 0o777
|
|
83
|
+
tmp.chmod(mode)
|
|
84
|
+
except OSError:
|
|
85
|
+
tmp.chmod(0o600)
|
|
86
|
+
else:
|
|
87
|
+
tmp.chmod(0o600)
|
|
88
|
+
tmp.replace(path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ensure_permission_floor(path: Path, mode: int = 0o600) -> None:
|
|
92
|
+
"""Force mode every run (devcake up permission floor)."""
|
|
93
|
+
if path.is_file():
|
|
94
|
+
path.chmod(mode)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generate_strong_password(*, length: int = 24) -> str:
|
|
98
|
+
"""Password meeting OpenObserve v0.91.5 classes + ADMIN 12-char floor."""
|
|
99
|
+
if length < 12:
|
|
100
|
+
length = 12
|
|
101
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()-_=+"
|
|
102
|
+
while True:
|
|
103
|
+
pw = "".join(secrets.choice(alphabet) for _ in range(length))
|
|
104
|
+
if (
|
|
105
|
+
any(c.islower() for c in pw)
|
|
106
|
+
and any(c.isupper() for c in pw)
|
|
107
|
+
and any(c.isdigit() for c in pw)
|
|
108
|
+
and any(c not in string.ascii_letters + string.digits for c in pw)
|
|
109
|
+
and pw.strip() not in _WEAK
|
|
110
|
+
):
|
|
111
|
+
return pw
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate_ingest_email() -> str:
|
|
115
|
+
return f"ingest-{secrets.token_hex(4)}@localhost.local"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def needs_generation(key: str, value: str) -> bool:
|
|
119
|
+
"""True when auto-init should fill this required bootstrap key."""
|
|
120
|
+
stripped = (value or "").strip()
|
|
121
|
+
if key == "OO_INGEST_EMAIL":
|
|
122
|
+
return not stripped
|
|
123
|
+
if key == "ADMIN_PASSWORD":
|
|
124
|
+
return stripped in _WEAK or len(stripped) < 12
|
|
125
|
+
return stripped in _WEAK
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def oo_password_ok(password: str) -> bool:
|
|
129
|
+
"""Mirror scripts/lib/oo_password.sh composition rule (non-empty assumed)."""
|
|
130
|
+
if not password:
|
|
131
|
+
return True # empty deferred to boot / auto-init
|
|
132
|
+
if not (8 <= len(password) <= 128):
|
|
133
|
+
return False
|
|
134
|
+
if not re.search(r"[a-z]", password):
|
|
135
|
+
return False
|
|
136
|
+
if not re.search(r"[A-Z]", password):
|
|
137
|
+
return False
|
|
138
|
+
if not re.search(r"[0-9]", password):
|
|
139
|
+
return False
|
|
140
|
+
if not re.search(r"[^a-zA-Z0-9]", password):
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def seed_env_from_example(env_path: Path, example_path: Path) -> bool:
|
|
146
|
+
"""Copy .env.example → .env when missing. Returns True if created."""
|
|
147
|
+
if env_path.is_file():
|
|
148
|
+
return False
|
|
149
|
+
if not example_path.is_file():
|
|
150
|
+
raise FileNotFoundError(
|
|
151
|
+
"no .env and no .env.example — create .env with bootstrap passwords first"
|
|
152
|
+
)
|
|
153
|
+
env_path.write_text(example_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
154
|
+
env_path.chmod(0o600)
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def auto_init_bootstrap(env_path: Path) -> list[str]:
|
|
159
|
+
"""Fill missing/empty required bootstrap keys. Returns generated key names.
|
|
160
|
+
|
|
161
|
+
Never echoes values. Existing non-weak values are preserved.
|
|
162
|
+
Process env does NOT silently override file contents here — callers that
|
|
163
|
+
want process-env precedence should upsert before calling this.
|
|
164
|
+
"""
|
|
165
|
+
generated: list[str] = []
|
|
166
|
+
current = parse_env_file(env_path)
|
|
167
|
+
for key in REQUIRED_BOOTSTRAP_KEYS:
|
|
168
|
+
# Process env wins when explicitly set (ADR precedence rule 1).
|
|
169
|
+
proc_val = os.environ.get(key)
|
|
170
|
+
if proc_val is not None and not needs_generation(key, proc_val):
|
|
171
|
+
if current.get(key) != proc_val:
|
|
172
|
+
upsert_env_var(key, proc_val, env_path)
|
|
173
|
+
current[key] = proc_val
|
|
174
|
+
continue
|
|
175
|
+
existing = current.get(key, "")
|
|
176
|
+
if not needs_generation(key, existing):
|
|
177
|
+
continue
|
|
178
|
+
if key == "OO_INGEST_EMAIL":
|
|
179
|
+
value = generate_ingest_email()
|
|
180
|
+
else:
|
|
181
|
+
value = generate_strong_password()
|
|
182
|
+
upsert_env_var(key, value, env_path)
|
|
183
|
+
current[key] = value
|
|
184
|
+
generated.append(key)
|
|
185
|
+
ensure_permission_floor(env_path)
|
|
186
|
+
return generated
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_oo_passwords(env_path: Path) -> None:
|
|
190
|
+
"""Raise ValueError when non-empty OO passwords violate composition."""
|
|
191
|
+
data = parse_env_file(env_path)
|
|
192
|
+
for key in ("OO_ROOT_PASSWORD", "OO_INGEST_PASSWORD"):
|
|
193
|
+
val = data.get(key, "")
|
|
194
|
+
if val and not oo_password_ok(val):
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{key} does not meet OpenObserve v0.91.5 password policy: "
|
|
197
|
+
f"must be 8-128 characters and contain at least one lowercase "
|
|
198
|
+
f"letter, one uppercase letter, one digit, and one special character"
|
|
199
|
+
)
|
devcake_cli/main.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Console entry for the ``devcake`` command.
|
|
2
|
+
|
|
3
|
+
Phase 1c (CAKE-178 / ADR-0038): ``baker run``, ``up``, ``down``, ``status``,
|
|
4
|
+
``doctor``, and ``setup``. ``bake`` remains a sibling stub (exit usage 2).
|
|
5
|
+
Universal ``--help`` / ``--json`` are accepted on the CLI surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from . import baker, doctor, down, setup, status, up
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_USAGE = """\
|
|
17
|
+
usage: devcake [--json] <verb> …
|
|
18
|
+
|
|
19
|
+
Implemented:
|
|
20
|
+
baker run Host baker foreground / supervisor entry
|
|
21
|
+
(same loop as python -m dev_factory)
|
|
22
|
+
up Bring up the compose stack (+ optional --bake)
|
|
23
|
+
down Stop the compose stack (no volume wipe)
|
|
24
|
+
status Compose + baker readiness snapshot
|
|
25
|
+
doctor Named preflight checks (+ remedies; --json)
|
|
26
|
+
setup First-setup / connections / settings-bundle import
|
|
27
|
+
|
|
28
|
+
Not yet implemented (ADR-0038 v1 — sibling issues):
|
|
29
|
+
bake
|
|
30
|
+
|
|
31
|
+
Install: uv tool install . OR pipx install .
|
|
32
|
+
Docs: docs/adr/0038-devcake-cli-scope-command-surface-and-agent-operability.md
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_UP_HELP = """\
|
|
36
|
+
usage: devcake up [--bake [targets…]] [--dry-run] [--foreground-baker]
|
|
37
|
+
[--no-hello-smoke] [--] [service…]
|
|
38
|
+
|
|
39
|
+
Bring up the DevCake stack with discovered DOCKER_GID.
|
|
40
|
+
--bake [targets…] bake before up (default targets: app admin hello)
|
|
41
|
+
--dry-run print discovered GID + planned actions
|
|
42
|
+
--foreground-baker up, then run baker in foreground (no supervisor)
|
|
43
|
+
--no-hello-smoke with --bake, skip hello dispatch smoke
|
|
44
|
+
[service…] or -- svc optional compose service names
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_VERBS = frozenset(
|
|
48
|
+
{"baker", "up", "down", "status", "doctor", "bake", "setup"}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_up_flags(argv: Sequence[str]) -> up.UpOptions | int:
|
|
53
|
+
"""Parse ``up`` argv (flags, ``--bake`` targets, compose services).
|
|
54
|
+
|
|
55
|
+
Returns ``UpOptions`` or an int exit code (0 for --help, 2 for usage).
|
|
56
|
+
"""
|
|
57
|
+
dry_run = False
|
|
58
|
+
foreground = False
|
|
59
|
+
no_hello = False
|
|
60
|
+
do_bake = False
|
|
61
|
+
bake_targets: list[str] = []
|
|
62
|
+
compose_args: list[str] = []
|
|
63
|
+
tokens = list(argv)
|
|
64
|
+
|
|
65
|
+
i = 0
|
|
66
|
+
while i < len(tokens):
|
|
67
|
+
tok = tokens[i]
|
|
68
|
+
if tok in ("-h", "--help"):
|
|
69
|
+
sys.stdout.write(_UP_HELP)
|
|
70
|
+
return 0
|
|
71
|
+
if tok == "--dry-run":
|
|
72
|
+
dry_run = True
|
|
73
|
+
i += 1
|
|
74
|
+
continue
|
|
75
|
+
if tok == "--foreground-baker":
|
|
76
|
+
foreground = True
|
|
77
|
+
i += 1
|
|
78
|
+
continue
|
|
79
|
+
if tok == "--no-hello-smoke":
|
|
80
|
+
no_hello = True
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
if tok == "--bake":
|
|
84
|
+
do_bake = True
|
|
85
|
+
i += 1
|
|
86
|
+
while i < len(tokens) and not tokens[i].startswith("--"):
|
|
87
|
+
bake_targets.append(tokens[i])
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
if tok == "--":
|
|
91
|
+
compose_args.extend(tokens[i + 1 :])
|
|
92
|
+
break
|
|
93
|
+
if tok.startswith("-"):
|
|
94
|
+
sys.stderr.write(f"unknown option: {tok} (try --help)\n")
|
|
95
|
+
return 2
|
|
96
|
+
compose_args.extend(tokens[i:])
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
return up.UpOptions(
|
|
100
|
+
bake=do_bake,
|
|
101
|
+
bake_targets=bake_targets,
|
|
102
|
+
dry_run=dry_run,
|
|
103
|
+
foreground_baker=foreground,
|
|
104
|
+
no_hello_smoke=no_hello,
|
|
105
|
+
compose_services=compose_args,
|
|
106
|
+
as_json=False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
111
|
+
"""CLI entry. Returns a process exit code (0 / 2 / 3 / 4 / 6 / …)."""
|
|
112
|
+
argv_list = list(sys.argv[1:] if argv is None else argv)
|
|
113
|
+
|
|
114
|
+
# Universal --json may appear before or after the verb (ADR-0038 Decision 2).
|
|
115
|
+
as_json = "--json" in argv_list
|
|
116
|
+
argv_list = [a for a in argv_list if a != "--json"]
|
|
117
|
+
|
|
118
|
+
if not argv_list or argv_list[0] in ("-h", "--help"):
|
|
119
|
+
if argv_list and argv_list[0] in ("-h", "--help"):
|
|
120
|
+
sys.stdout.write(_USAGE)
|
|
121
|
+
return 0
|
|
122
|
+
sys.stderr.write(_USAGE)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
verb = argv_list[0]
|
|
126
|
+
rest = argv_list[1:]
|
|
127
|
+
|
|
128
|
+
if verb not in _VERBS:
|
|
129
|
+
sys.stderr.write(f"devcake: unknown verb {verb!r}\n")
|
|
130
|
+
sys.stderr.write(_USAGE)
|
|
131
|
+
return 2
|
|
132
|
+
|
|
133
|
+
if verb == "baker":
|
|
134
|
+
if rest == ["run"] or (rest and rest[0] == "run" and rest[1:] == []):
|
|
135
|
+
return baker.run()
|
|
136
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
137
|
+
sys.stdout.write("usage: devcake baker run\n")
|
|
138
|
+
return 0
|
|
139
|
+
sys.stderr.write("usage: devcake baker run\n")
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
if verb == "doctor":
|
|
143
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
144
|
+
sys.stdout.write(
|
|
145
|
+
"usage: devcake doctor [--json]\n"
|
|
146
|
+
"Named preflight checks; prints one-time remedies. "
|
|
147
|
+
"Never runs sudo/usermod/linger.\n"
|
|
148
|
+
)
|
|
149
|
+
return 0
|
|
150
|
+
if rest:
|
|
151
|
+
sys.stderr.write(f"devcake doctor: unknown option {rest[0]!r}\n")
|
|
152
|
+
return 2
|
|
153
|
+
return doctor.run_doctor(as_json=as_json)
|
|
154
|
+
|
|
155
|
+
if verb == "down":
|
|
156
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
157
|
+
sys.stdout.write(
|
|
158
|
+
"usage: devcake down [--json]\n"
|
|
159
|
+
"Stop the compose stack (docker compose down; never -v).\n"
|
|
160
|
+
)
|
|
161
|
+
return 0
|
|
162
|
+
if rest:
|
|
163
|
+
sys.stderr.write(f"devcake down: unknown option {rest[0]!r}\n")
|
|
164
|
+
return 2
|
|
165
|
+
return down.run_down(as_json=as_json)
|
|
166
|
+
|
|
167
|
+
if verb == "status":
|
|
168
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
169
|
+
sys.stdout.write(
|
|
170
|
+
"usage: devcake status [--json]\n"
|
|
171
|
+
"Compose project + baker liveness snapshot.\n"
|
|
172
|
+
)
|
|
173
|
+
return 0
|
|
174
|
+
if rest:
|
|
175
|
+
sys.stderr.write(f"devcake status: unknown option {rest[0]!r}\n")
|
|
176
|
+
return 2
|
|
177
|
+
return status.run_status(as_json=as_json)
|
|
178
|
+
|
|
179
|
+
if verb == "up":
|
|
180
|
+
parsed = parse_up_flags(rest)
|
|
181
|
+
if isinstance(parsed, int):
|
|
182
|
+
return parsed
|
|
183
|
+
parsed.as_json = as_json
|
|
184
|
+
return up.run_up(parsed)
|
|
185
|
+
|
|
186
|
+
if verb == "setup":
|
|
187
|
+
parsed = setup.parse_setup_flags(rest)
|
|
188
|
+
if isinstance(parsed, int):
|
|
189
|
+
return parsed
|
|
190
|
+
parsed.as_json = as_json
|
|
191
|
+
return setup.run_setup(parsed)
|
|
192
|
+
|
|
193
|
+
# bake — registered but not yet implemented (sibling issue)
|
|
194
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
195
|
+
sys.stdout.write(
|
|
196
|
+
f"usage: devcake {verb}\n"
|
|
197
|
+
f"(not yet implemented — ADR-0038 sibling issue)\n"
|
|
198
|
+
)
|
|
199
|
+
return 0
|
|
200
|
+
sys.stderr.write(
|
|
201
|
+
f"devcake: '{verb}' is not implemented yet "
|
|
202
|
+
f"(ADR-0038 — see sibling issues)\n"
|
|
203
|
+
f"see: docs/adr/0038-devcake-cli-scope-command-surface-and-agent-operability.md\n"
|
|
204
|
+
)
|
|
205
|
+
return 2
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
raise SystemExit(main())
|
devcake_cli/paths.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Checkout-root discovery for the host CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_checkout_root(start: Path | None = None) -> Path | None:
|
|
9
|
+
"""Walk upward for ``docker-compose.yml`` + ``docker-bake.hcl``.
|
|
10
|
+
|
|
11
|
+
Returns ``None`` when no DevCake checkout layout is found.
|
|
12
|
+
"""
|
|
13
|
+
cur = (start or Path.cwd()).resolve()
|
|
14
|
+
for candidate in (cur, *cur.parents):
|
|
15
|
+
if (candidate / "docker-compose.yml").is_file() and (
|
|
16
|
+
candidate / "docker-bake.hcl"
|
|
17
|
+
).is_file():
|
|
18
|
+
return candidate
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_checkout_root(start: Path | None = None) -> Path:
|
|
23
|
+
root = find_checkout_root(start)
|
|
24
|
+
if root is None:
|
|
25
|
+
raise FileNotFoundError(
|
|
26
|
+
"not a DevCake checkout (need docker-compose.yml + docker-bake.hcl); "
|
|
27
|
+
"cd to the repo root or re-clone"
|
|
28
|
+
)
|
|
29
|
+
return root
|