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/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