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/setup.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
"""``devcake setup`` — configure a reachable control plane (ADR-0038 Decision 1).
|
|
2
|
+
|
|
3
|
+
Does **not** bake, compose-up, wait healthy, or hello-smoke (Decision 8).
|
|
4
|
+
Slices: Dev Type first-setup, PMO/repo connections + secrets, settings-bundle
|
|
5
|
+
import→profile→apply (+ host ``.env`` for section C), doctor subset + receipt.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from . import doctor, envfile
|
|
21
|
+
from .paths import require_checkout_root
|
|
22
|
+
|
|
23
|
+
_ROLES: tuple[str, ...] = ("judge", "executor", "steward")
|
|
24
|
+
# The compose stack publishes the app API on the host ONLY through the admin
|
|
25
|
+
# proxy (docker-compose.yml: admin binds 127.0.0.1:8080; the app service has
|
|
26
|
+
# no host port — 8000 exists only inside the app container).
|
|
27
|
+
_DEFAULT_BASE_URL = "http://localhost:8080"
|
|
28
|
+
|
|
29
|
+
_SETUP_HELP = """\
|
|
30
|
+
usage: devcake setup [flags…] [--json]
|
|
31
|
+
|
|
32
|
+
Configure an already-reachable DevCake control plane (ADR-0038).
|
|
33
|
+
Does not start, stop, bake, or wait on the compose stack — run
|
|
34
|
+
`devcake up --bake` first. Clean-host chain:
|
|
35
|
+
|
|
36
|
+
devcake up --bake && devcake setup … --json
|
|
37
|
+
|
|
38
|
+
Dev Type first-setup (create-once; HTTP 409 → exit 5):
|
|
39
|
+
--role-harness <role>=<template> repeatable; role ∈ judge,executor,steward
|
|
40
|
+
--role-model <role>=<model> repeatable; empty model = harness default
|
|
41
|
+
--same-harness <template> apply one harness to all three roles
|
|
42
|
+
--same-model <model> with --same-harness (optional)
|
|
43
|
+
|
|
44
|
+
PMO connection (upsert by name):
|
|
45
|
+
--pmo-name <name> required when any --pmo-* is set
|
|
46
|
+
--pmo-system <system> default: linear
|
|
47
|
+
--pmo-team-key <key> team / board key (not a secret)
|
|
48
|
+
--pmo-api-base <url> optional API base override
|
|
49
|
+
--pmo-api-key-env <VAR> secret from env (never argv value)
|
|
50
|
+
--pmo-api-key-file <path> secret from file
|
|
51
|
+
--pmo-api-key-stdin secret from stdin
|
|
52
|
+
|
|
53
|
+
Repo connection (upsert by name):
|
|
54
|
+
--repo-name <name> required when any --repo-* is set
|
|
55
|
+
--repo-forge <forge> default: github
|
|
56
|
+
--repo-url <url> repository URL
|
|
57
|
+
--repo-api-base <url> optional API base override
|
|
58
|
+
--repo-token-env <VAR> write token from env
|
|
59
|
+
--repo-token-file <path> write token from file
|
|
60
|
+
--repo-token-stdin write token from stdin
|
|
61
|
+
|
|
62
|
+
Settings-bundle import (ADR-0013 import→profile→apply):
|
|
63
|
+
--import <bundle.yaml> kind: devcake-settings-bundle
|
|
64
|
+
--import-passphrase-env <VAR> passphrase from env
|
|
65
|
+
--import-passphrase-file <path> passphrase from file
|
|
66
|
+
--import-passphrase-stdin passphrase from stdin
|
|
67
|
+
--import-overwrite overwrite existing profile name
|
|
68
|
+
--import-profile <name> profile save-as (default: imported-<stem>)
|
|
69
|
+
|
|
70
|
+
Control plane:
|
|
71
|
+
--base-url <url> default http://localhost:8080 (the admin
|
|
72
|
+
proxy publishes the app API on loopback)
|
|
73
|
+
|
|
74
|
+
Universal: --help, --json
|
|
75
|
+
Exit codes: 0 ok · 2 usage · 3 doctor hard-fail · 5 first-setup conflict · 1 other
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class SetupOptions:
|
|
81
|
+
as_json: bool = False
|
|
82
|
+
role_harness: dict[str, str] = field(default_factory=dict)
|
|
83
|
+
role_model: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
same_harness: str | None = None
|
|
85
|
+
same_model: str | None = None
|
|
86
|
+
pmo_name: str | None = None
|
|
87
|
+
pmo_system: str | None = None
|
|
88
|
+
pmo_team_key: str | None = None
|
|
89
|
+
pmo_api_base: str | None = None
|
|
90
|
+
pmo_api_key_source: tuple[str, str] | None = None # kind, ref
|
|
91
|
+
repo_name: str | None = None
|
|
92
|
+
repo_forge: str | None = None
|
|
93
|
+
repo_url: str | None = None
|
|
94
|
+
repo_api_base: str | None = None
|
|
95
|
+
repo_token_source: tuple[str, str] | None = None
|
|
96
|
+
import_path: Path | None = None
|
|
97
|
+
import_passphrase_source: tuple[str, str] | None = None
|
|
98
|
+
import_overwrite: bool = False
|
|
99
|
+
import_profile: str | None = None
|
|
100
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
HttpFn = Callable[[str, str, dict | None, dict[str, str]], tuple[int, Any]]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class UsageError(Exception):
|
|
107
|
+
"""Bad flags / missing required input → exit 2."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_setup_flags(argv: Sequence[str]) -> SetupOptions | int:
|
|
111
|
+
"""Parse setup argv. Returns SetupOptions or exit code (0 help / 2 usage)."""
|
|
112
|
+
opts = SetupOptions()
|
|
113
|
+
tokens = list(argv)
|
|
114
|
+
i = 0
|
|
115
|
+
|
|
116
|
+
def need_value(flag: str) -> str:
|
|
117
|
+
nonlocal i
|
|
118
|
+
i += 1
|
|
119
|
+
if i >= len(tokens):
|
|
120
|
+
raise UsageError(f"{flag} requires a value")
|
|
121
|
+
return tokens[i]
|
|
122
|
+
|
|
123
|
+
def set_secret_source(
|
|
124
|
+
current: tuple[str, str] | None,
|
|
125
|
+
kind: str,
|
|
126
|
+
ref: str,
|
|
127
|
+
label: str,
|
|
128
|
+
) -> tuple[str, str]:
|
|
129
|
+
if current is not None:
|
|
130
|
+
raise UsageError(
|
|
131
|
+
f"{label}: mutually exclusive env/file/stdin — pick one"
|
|
132
|
+
)
|
|
133
|
+
return (kind, ref)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
while i < len(tokens):
|
|
137
|
+
tok = tokens[i]
|
|
138
|
+
if tok in ("-h", "--help"):
|
|
139
|
+
sys.stdout.write(_SETUP_HELP)
|
|
140
|
+
return 0
|
|
141
|
+
if tok == "--role-harness":
|
|
142
|
+
raw = need_value(tok)
|
|
143
|
+
role, _, template = raw.partition("=")
|
|
144
|
+
if not role or not template or role not in _ROLES:
|
|
145
|
+
raise UsageError(
|
|
146
|
+
f"--role-harness expects <role>=<template> "
|
|
147
|
+
f"with role ∈ {','.join(_ROLES)}"
|
|
148
|
+
)
|
|
149
|
+
opts.role_harness[role] = template
|
|
150
|
+
elif tok == "--role-model":
|
|
151
|
+
raw = need_value(tok)
|
|
152
|
+
role, _, model = raw.partition("=")
|
|
153
|
+
if not role or "=" not in raw or role not in _ROLES:
|
|
154
|
+
raise UsageError(
|
|
155
|
+
f"--role-model expects <role>=<model> "
|
|
156
|
+
f"with role ∈ {','.join(_ROLES)}"
|
|
157
|
+
)
|
|
158
|
+
opts.role_model[role] = model
|
|
159
|
+
elif tok == "--same-harness":
|
|
160
|
+
opts.same_harness = need_value(tok)
|
|
161
|
+
elif tok == "--same-model":
|
|
162
|
+
opts.same_model = need_value(tok)
|
|
163
|
+
elif tok == "--pmo-name":
|
|
164
|
+
opts.pmo_name = need_value(tok)
|
|
165
|
+
elif tok == "--pmo-system":
|
|
166
|
+
opts.pmo_system = need_value(tok)
|
|
167
|
+
elif tok == "--pmo-team-key":
|
|
168
|
+
opts.pmo_team_key = need_value(tok)
|
|
169
|
+
elif tok == "--pmo-api-base":
|
|
170
|
+
opts.pmo_api_base = need_value(tok)
|
|
171
|
+
elif tok == "--pmo-api-key-env":
|
|
172
|
+
opts.pmo_api_key_source = set_secret_source(
|
|
173
|
+
opts.pmo_api_key_source, "env", need_value(tok), "pmo-api-key"
|
|
174
|
+
)
|
|
175
|
+
elif tok == "--pmo-api-key-file":
|
|
176
|
+
opts.pmo_api_key_source = set_secret_source(
|
|
177
|
+
opts.pmo_api_key_source, "file", need_value(tok), "pmo-api-key"
|
|
178
|
+
)
|
|
179
|
+
elif tok == "--pmo-api-key-stdin":
|
|
180
|
+
opts.pmo_api_key_source = set_secret_source(
|
|
181
|
+
opts.pmo_api_key_source, "stdin", "", "pmo-api-key"
|
|
182
|
+
)
|
|
183
|
+
elif tok == "--repo-name":
|
|
184
|
+
opts.repo_name = need_value(tok)
|
|
185
|
+
elif tok == "--repo-forge":
|
|
186
|
+
opts.repo_forge = need_value(tok)
|
|
187
|
+
elif tok == "--repo-url":
|
|
188
|
+
opts.repo_url = need_value(tok)
|
|
189
|
+
elif tok == "--repo-api-base":
|
|
190
|
+
opts.repo_api_base = need_value(tok)
|
|
191
|
+
elif tok == "--repo-token-env":
|
|
192
|
+
opts.repo_token_source = set_secret_source(
|
|
193
|
+
opts.repo_token_source, "env", need_value(tok), "repo-token"
|
|
194
|
+
)
|
|
195
|
+
elif tok == "--repo-token-file":
|
|
196
|
+
opts.repo_token_source = set_secret_source(
|
|
197
|
+
opts.repo_token_source, "file", need_value(tok), "repo-token"
|
|
198
|
+
)
|
|
199
|
+
elif tok == "--repo-token-stdin":
|
|
200
|
+
opts.repo_token_source = set_secret_source(
|
|
201
|
+
opts.repo_token_source, "stdin", "", "repo-token"
|
|
202
|
+
)
|
|
203
|
+
elif tok == "--import":
|
|
204
|
+
opts.import_path = Path(need_value(tok))
|
|
205
|
+
elif tok == "--import-passphrase-env":
|
|
206
|
+
opts.import_passphrase_source = set_secret_source(
|
|
207
|
+
opts.import_passphrase_source,
|
|
208
|
+
"env",
|
|
209
|
+
need_value(tok),
|
|
210
|
+
"import-passphrase",
|
|
211
|
+
)
|
|
212
|
+
elif tok == "--import-passphrase-file":
|
|
213
|
+
opts.import_passphrase_source = set_secret_source(
|
|
214
|
+
opts.import_passphrase_source,
|
|
215
|
+
"file",
|
|
216
|
+
need_value(tok),
|
|
217
|
+
"import-passphrase",
|
|
218
|
+
)
|
|
219
|
+
elif tok == "--import-passphrase-stdin":
|
|
220
|
+
opts.import_passphrase_source = set_secret_source(
|
|
221
|
+
opts.import_passphrase_source,
|
|
222
|
+
"stdin",
|
|
223
|
+
"",
|
|
224
|
+
"import-passphrase",
|
|
225
|
+
)
|
|
226
|
+
elif tok == "--import-overwrite":
|
|
227
|
+
opts.import_overwrite = True
|
|
228
|
+
elif tok == "--import-profile":
|
|
229
|
+
opts.import_profile = need_value(tok)
|
|
230
|
+
elif tok == "--base-url":
|
|
231
|
+
opts.base_url = need_value(tok).rstrip("/")
|
|
232
|
+
elif tok.startswith("-"):
|
|
233
|
+
raise UsageError(f"unknown option: {tok} (try --help)")
|
|
234
|
+
else:
|
|
235
|
+
raise UsageError(f"unexpected argument: {tok!r}")
|
|
236
|
+
i += 1
|
|
237
|
+
except UsageError as e:
|
|
238
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
_validate_options(opts)
|
|
243
|
+
except UsageError as e:
|
|
244
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
245
|
+
return 2
|
|
246
|
+
return opts
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validate_options(opts: SetupOptions) -> None:
|
|
250
|
+
if opts.same_model is not None and not opts.same_harness:
|
|
251
|
+
raise UsageError("--same-model requires --same-harness")
|
|
252
|
+
|
|
253
|
+
# Expand same-harness / same-model into per-role maps; reject disagreements.
|
|
254
|
+
if opts.same_harness:
|
|
255
|
+
for role in _ROLES:
|
|
256
|
+
existing = opts.role_harness.get(role)
|
|
257
|
+
if existing is not None and existing != opts.same_harness:
|
|
258
|
+
raise UsageError(
|
|
259
|
+
f"--same-harness {opts.same_harness!r} disagrees with "
|
|
260
|
+
f"--role-harness {role}={existing}"
|
|
261
|
+
)
|
|
262
|
+
opts.role_harness[role] = opts.same_harness
|
|
263
|
+
if opts.same_model is not None:
|
|
264
|
+
for role in _ROLES:
|
|
265
|
+
existing = opts.role_model.get(role)
|
|
266
|
+
if existing is not None and existing != opts.same_model:
|
|
267
|
+
raise UsageError(
|
|
268
|
+
f"--same-model {opts.same_model!r} disagrees with "
|
|
269
|
+
f"--role-model {role}={existing}"
|
|
270
|
+
)
|
|
271
|
+
opts.role_model[role] = opts.same_model
|
|
272
|
+
|
|
273
|
+
wants_roster = bool(opts.role_harness) or bool(opts.same_harness)
|
|
274
|
+
if wants_roster:
|
|
275
|
+
missing = [r for r in _ROLES if r not in opts.role_harness]
|
|
276
|
+
if missing:
|
|
277
|
+
raise UsageError(
|
|
278
|
+
"first-setup requires harness for all roles "
|
|
279
|
+
f"(missing: {', '.join(missing)}); use --same-harness "
|
|
280
|
+
"or --role-harness for each"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
pmo_set = any(
|
|
284
|
+
v is not None
|
|
285
|
+
for v in (
|
|
286
|
+
opts.pmo_name,
|
|
287
|
+
opts.pmo_system,
|
|
288
|
+
opts.pmo_team_key,
|
|
289
|
+
opts.pmo_api_base,
|
|
290
|
+
opts.pmo_api_key_source,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
if pmo_set and not opts.pmo_name:
|
|
294
|
+
raise UsageError("--pmo-name is required when configuring a PMO")
|
|
295
|
+
|
|
296
|
+
repo_set = any(
|
|
297
|
+
v is not None
|
|
298
|
+
for v in (
|
|
299
|
+
opts.repo_name,
|
|
300
|
+
opts.repo_forge,
|
|
301
|
+
opts.repo_url,
|
|
302
|
+
opts.repo_api_base,
|
|
303
|
+
opts.repo_token_source,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
if repo_set and not opts.repo_name:
|
|
307
|
+
raise UsageError("--repo-name is required when configuring a repo")
|
|
308
|
+
|
|
309
|
+
if opts.import_passphrase_source and not opts.import_path:
|
|
310
|
+
raise UsageError("--import is required when a passphrase source is set")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _read_secret(source: tuple[str, str], *, label: str) -> str:
|
|
314
|
+
kind, ref = source
|
|
315
|
+
if kind == "env":
|
|
316
|
+
val = os.environ.get(ref)
|
|
317
|
+
if val is None or val == "":
|
|
318
|
+
raise UsageError(f"{label}: env var {ref!r} is unset or empty")
|
|
319
|
+
return val
|
|
320
|
+
if kind == "file":
|
|
321
|
+
path = Path(ref)
|
|
322
|
+
if not path.is_file():
|
|
323
|
+
raise UsageError(f"{label}: file not found: {ref}")
|
|
324
|
+
val = path.read_text(encoding="utf-8").rstrip("\n")
|
|
325
|
+
if not val:
|
|
326
|
+
raise UsageError(f"{label}: file {ref} is empty")
|
|
327
|
+
return val
|
|
328
|
+
if kind == "stdin":
|
|
329
|
+
val = sys.stdin.read().rstrip("\n")
|
|
330
|
+
if not val:
|
|
331
|
+
raise UsageError(f"{label}: stdin is empty")
|
|
332
|
+
return val
|
|
333
|
+
raise UsageError(f"{label}: unknown secret source {kind!r}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _basic_auth_header(user: str, password: str) -> str:
|
|
337
|
+
tok = base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
338
|
+
return f"Basic {tok}"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _load_admin_auth(repo: Path) -> tuple[str, str]:
|
|
342
|
+
data = envfile.parse_env_file(repo / ".env")
|
|
343
|
+
user = (data.get("ADMIN_USER") or "").strip()
|
|
344
|
+
password = (data.get("ADMIN_PASSWORD") or "").strip()
|
|
345
|
+
if not user or not password:
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
"ADMIN_USER / ADMIN_PASSWORD missing from checkout .env — "
|
|
348
|
+
"run `devcake up` first (auto-init) or set them manually"
|
|
349
|
+
)
|
|
350
|
+
return user, password
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def default_http(
|
|
354
|
+
method: str,
|
|
355
|
+
url: str,
|
|
356
|
+
body: dict | None,
|
|
357
|
+
headers: dict[str, str],
|
|
358
|
+
) -> tuple[int, Any]:
|
|
359
|
+
"""stdlib HTTP JSON helper (no httpx dependency in the CLI package)."""
|
|
360
|
+
data = None
|
|
361
|
+
req_headers = dict(headers)
|
|
362
|
+
if body is not None:
|
|
363
|
+
data = json.dumps(body).encode()
|
|
364
|
+
req_headers.setdefault("Content-Type", "application/json")
|
|
365
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
|
366
|
+
try:
|
|
367
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
368
|
+
raw = resp.read()
|
|
369
|
+
status = getattr(resp, "status", 200) or 200
|
|
370
|
+
if not raw:
|
|
371
|
+
return status, {}
|
|
372
|
+
try:
|
|
373
|
+
return status, json.loads(raw.decode())
|
|
374
|
+
except json.JSONDecodeError:
|
|
375
|
+
return status, raw.decode(errors="replace")
|
|
376
|
+
except urllib.error.HTTPError as e:
|
|
377
|
+
raw = e.read()
|
|
378
|
+
try:
|
|
379
|
+
payload: Any = json.loads(raw.decode()) if raw else {"detail": str(e)}
|
|
380
|
+
except json.JSONDecodeError:
|
|
381
|
+
payload = raw.decode(errors="replace") if raw else str(e)
|
|
382
|
+
return e.code, payload
|
|
383
|
+
except urllib.error.URLError as e:
|
|
384
|
+
raise RuntimeError(f"cannot reach control plane at {url}: {e.reason}") from e
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _api(
|
|
388
|
+
http: HttpFn,
|
|
389
|
+
method: str,
|
|
390
|
+
base: str,
|
|
391
|
+
path: str,
|
|
392
|
+
body: dict | None,
|
|
393
|
+
auth_header: str,
|
|
394
|
+
) -> tuple[int, Any]:
|
|
395
|
+
url = base.rstrip("/") + path
|
|
396
|
+
# Control-plane auth requires X-DevCake-Request: 1 on every mutating
|
|
397
|
+
# method (POST/PUT/PATCH/DELETE); without it the app returns 403
|
|
398
|
+
# "missing request intent header". Send on all setup calls (SPA does too).
|
|
399
|
+
return http(
|
|
400
|
+
method,
|
|
401
|
+
url,
|
|
402
|
+
body,
|
|
403
|
+
{
|
|
404
|
+
"Authorization": auth_header,
|
|
405
|
+
"X-DevCake-Request": "1",
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _build_roles_body(opts: SetupOptions) -> dict[str, dict[str, str]]:
|
|
411
|
+
roles: dict[str, dict[str, str]] = {}
|
|
412
|
+
for role in _ROLES:
|
|
413
|
+
roles[role] = {
|
|
414
|
+
"harness_template": opts.role_harness[role],
|
|
415
|
+
"model": opts.role_model.get(role, ""),
|
|
416
|
+
}
|
|
417
|
+
return roles
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _wants_roster(opts: SetupOptions) -> bool:
|
|
421
|
+
return bool(opts.role_harness)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _wants_pmo(opts: SetupOptions) -> bool:
|
|
425
|
+
return opts.pmo_name is not None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _wants_repo(opts: SetupOptions) -> bool:
|
|
429
|
+
return opts.repo_name is not None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _run_first_setup(
|
|
433
|
+
opts: SetupOptions,
|
|
434
|
+
*,
|
|
435
|
+
http: HttpFn,
|
|
436
|
+
auth_header: str,
|
|
437
|
+
receipt: dict[str, Any],
|
|
438
|
+
) -> int | None:
|
|
439
|
+
"""Returns an exit code on terminal failure, else None."""
|
|
440
|
+
if not _wants_roster(opts):
|
|
441
|
+
return None
|
|
442
|
+
roles = _build_roles_body(opts)
|
|
443
|
+
status, payload = _api(
|
|
444
|
+
http,
|
|
445
|
+
"POST",
|
|
446
|
+
opts.base_url,
|
|
447
|
+
"/api/v1/dev-types/first-setup",
|
|
448
|
+
{"roles": roles},
|
|
449
|
+
auth_header,
|
|
450
|
+
)
|
|
451
|
+
if status == 409:
|
|
452
|
+
receipt["ok"] = False
|
|
453
|
+
receipt["roles_created"] = []
|
|
454
|
+
receipt["roles"] = {
|
|
455
|
+
role: {
|
|
456
|
+
"harness_template": roles[role]["harness_template"],
|
|
457
|
+
"model": roles[role]["model"],
|
|
458
|
+
"created": False,
|
|
459
|
+
}
|
|
460
|
+
for role in _ROLES
|
|
461
|
+
}
|
|
462
|
+
detail = _detail(payload)
|
|
463
|
+
receipt["next_steps"].append(
|
|
464
|
+
f"first-setup conflict (roster non-empty): {detail}"
|
|
465
|
+
)
|
|
466
|
+
sys.stderr.write(f"devcake setup: first-setup conflict: {detail}\n")
|
|
467
|
+
return 5
|
|
468
|
+
if status >= 400:
|
|
469
|
+
receipt["ok"] = False
|
|
470
|
+
detail = _detail(payload)
|
|
471
|
+
sys.stderr.write(f"devcake setup: first-setup failed ({status}): {detail}\n")
|
|
472
|
+
return 1
|
|
473
|
+
created = list(payload.get("created") or _ROLES) if isinstance(payload, dict) else list(_ROLES)
|
|
474
|
+
receipt["roles_created"] = created
|
|
475
|
+
receipt["roles"] = {
|
|
476
|
+
role: {
|
|
477
|
+
"harness_template": roles[role]["harness_template"],
|
|
478
|
+
"model": roles[role]["model"],
|
|
479
|
+
"created": True,
|
|
480
|
+
}
|
|
481
|
+
for role in _ROLES
|
|
482
|
+
}
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _detail(payload: Any) -> str:
|
|
487
|
+
if isinstance(payload, dict):
|
|
488
|
+
d = payload.get("detail", payload)
|
|
489
|
+
return str(d)
|
|
490
|
+
return str(payload)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _upsert_pmo(
|
|
494
|
+
opts: SetupOptions,
|
|
495
|
+
*,
|
|
496
|
+
http: HttpFn,
|
|
497
|
+
auth_header: str,
|
|
498
|
+
receipt: dict[str, Any],
|
|
499
|
+
) -> int | None:
|
|
500
|
+
if not _wants_pmo(opts):
|
|
501
|
+
return None
|
|
502
|
+
status, cfg = _api(http, "GET", opts.base_url, "/api/v1/config", None, auth_header)
|
|
503
|
+
if status >= 400 or not isinstance(cfg, dict):
|
|
504
|
+
sys.stderr.write(f"devcake setup: GET /config failed ({status}): {_detail(cfg)}\n")
|
|
505
|
+
receipt["ok"] = False
|
|
506
|
+
return 1
|
|
507
|
+
pmos = list(cfg.get("pmos") or [])
|
|
508
|
+
name = opts.pmo_name or ""
|
|
509
|
+
idx = next((i for i, p in enumerate(pmos) if p.get("name") == name), None)
|
|
510
|
+
card: dict[str, Any] = dict(pmos[idx]) if idx is not None else {"name": name}
|
|
511
|
+
if opts.pmo_system is not None:
|
|
512
|
+
card["system"] = opts.pmo_system
|
|
513
|
+
elif "system" not in card:
|
|
514
|
+
card["system"] = "linear"
|
|
515
|
+
if opts.pmo_team_key is not None:
|
|
516
|
+
card["team_key"] = opts.pmo_team_key
|
|
517
|
+
if opts.pmo_api_base is not None:
|
|
518
|
+
card["api_base"] = opts.pmo_api_base
|
|
519
|
+
if idx is None:
|
|
520
|
+
pmos.append(card)
|
|
521
|
+
else:
|
|
522
|
+
pmos[idx] = card
|
|
523
|
+
put_body = {**cfg, "pmos": pmos}
|
|
524
|
+
status, put_out = _api(
|
|
525
|
+
http, "PUT", opts.base_url, "/api/v1/config", put_body, auth_header
|
|
526
|
+
)
|
|
527
|
+
if status >= 400:
|
|
528
|
+
sys.stderr.write(
|
|
529
|
+
f"devcake setup: PUT /config (pmo) failed ({status}): {_detail(put_out)}\n"
|
|
530
|
+
)
|
|
531
|
+
receipt["ok"] = False
|
|
532
|
+
return 1
|
|
533
|
+
|
|
534
|
+
configured = True
|
|
535
|
+
if opts.pmo_api_key_source:
|
|
536
|
+
try:
|
|
537
|
+
secret = _read_secret(opts.pmo_api_key_source, label="pmo-api-key")
|
|
538
|
+
except UsageError as e:
|
|
539
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
540
|
+
receipt["ok"] = False
|
|
541
|
+
return 2
|
|
542
|
+
status, sec_out = _api(
|
|
543
|
+
http,
|
|
544
|
+
"PUT",
|
|
545
|
+
opts.base_url,
|
|
546
|
+
f"/api/v1/secrets/pmo/{name}/api_key",
|
|
547
|
+
{"value": secret},
|
|
548
|
+
auth_header,
|
|
549
|
+
)
|
|
550
|
+
if status >= 400:
|
|
551
|
+
sys.stderr.write(
|
|
552
|
+
f"devcake setup: put pmo secret failed ({status}): {_detail(sec_out)}\n"
|
|
553
|
+
)
|
|
554
|
+
receipt["ok"] = False
|
|
555
|
+
return 1
|
|
556
|
+
receipt.setdefault("secrets_received", {})["pmo_api_key"] = True
|
|
557
|
+
else:
|
|
558
|
+
receipt.setdefault("secrets_received", {}).setdefault("pmo_api_key", False)
|
|
559
|
+
|
|
560
|
+
receipt.setdefault("connections", {}).setdefault("pmo", []).append(
|
|
561
|
+
{"name": name, "configured": configured, "tested": False}
|
|
562
|
+
)
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _upsert_repo(
|
|
567
|
+
opts: SetupOptions,
|
|
568
|
+
*,
|
|
569
|
+
http: HttpFn,
|
|
570
|
+
auth_header: str,
|
|
571
|
+
receipt: dict[str, Any],
|
|
572
|
+
) -> int | None:
|
|
573
|
+
if not _wants_repo(opts):
|
|
574
|
+
return None
|
|
575
|
+
status, cfg = _api(http, "GET", opts.base_url, "/api/v1/config", None, auth_header)
|
|
576
|
+
if status >= 400 or not isinstance(cfg, dict):
|
|
577
|
+
sys.stderr.write(f"devcake setup: GET /config failed ({status}): {_detail(cfg)}\n")
|
|
578
|
+
receipt["ok"] = False
|
|
579
|
+
return 1
|
|
580
|
+
repos = list(cfg.get("repos") or [])
|
|
581
|
+
name = opts.repo_name or ""
|
|
582
|
+
idx = next((i for i, r in enumerate(repos) if r.get("name") == name), None)
|
|
583
|
+
card: dict[str, Any] = dict(repos[idx]) if idx is not None else {"name": name}
|
|
584
|
+
if opts.repo_forge is not None:
|
|
585
|
+
card["forge"] = opts.repo_forge
|
|
586
|
+
elif "forge" not in card:
|
|
587
|
+
card["forge"] = "github"
|
|
588
|
+
if opts.repo_url is not None:
|
|
589
|
+
card["url"] = opts.repo_url
|
|
590
|
+
if opts.repo_api_base is not None:
|
|
591
|
+
card["api_base"] = opts.repo_api_base
|
|
592
|
+
if idx is None:
|
|
593
|
+
repos.append(card)
|
|
594
|
+
else:
|
|
595
|
+
repos[idx] = card
|
|
596
|
+
put_body = {**cfg, "repos": repos}
|
|
597
|
+
status, put_out = _api(
|
|
598
|
+
http, "PUT", opts.base_url, "/api/v1/config", put_body, auth_header
|
|
599
|
+
)
|
|
600
|
+
if status >= 400:
|
|
601
|
+
sys.stderr.write(
|
|
602
|
+
f"devcake setup: PUT /config (repo) failed ({status}): {_detail(put_out)}\n"
|
|
603
|
+
)
|
|
604
|
+
receipt["ok"] = False
|
|
605
|
+
return 1
|
|
606
|
+
|
|
607
|
+
if opts.repo_token_source:
|
|
608
|
+
try:
|
|
609
|
+
secret = _read_secret(opts.repo_token_source, label="repo-token")
|
|
610
|
+
except UsageError as e:
|
|
611
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
612
|
+
receipt["ok"] = False
|
|
613
|
+
return 2
|
|
614
|
+
status, sec_out = _api(
|
|
615
|
+
http,
|
|
616
|
+
"PUT",
|
|
617
|
+
opts.base_url,
|
|
618
|
+
f"/api/v1/secrets/repo/{name}/token",
|
|
619
|
+
{"value": secret},
|
|
620
|
+
auth_header,
|
|
621
|
+
)
|
|
622
|
+
if status >= 400:
|
|
623
|
+
sys.stderr.write(
|
|
624
|
+
f"devcake setup: put repo secret failed ({status}): {_detail(sec_out)}\n"
|
|
625
|
+
)
|
|
626
|
+
receipt["ok"] = False
|
|
627
|
+
return 1
|
|
628
|
+
sr = receipt.setdefault("secrets_received", {})
|
|
629
|
+
sr["repo_token_count"] = int(sr.get("repo_token_count") or 0) + 1
|
|
630
|
+
else:
|
|
631
|
+
receipt.setdefault("secrets_received", {}).setdefault("repo_token_count", 0)
|
|
632
|
+
|
|
633
|
+
receipt.setdefault("connections", {}).setdefault("repos", []).append(
|
|
634
|
+
{"name": name, "configured": True, "tested": False}
|
|
635
|
+
)
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _apply_setup_env_to_host(repo: Path, values: dict[str, str]) -> list[str]:
|
|
640
|
+
"""Write section-C key names into checkout .env (mode 600). Never log values."""
|
|
641
|
+
env_path = repo / ".env"
|
|
642
|
+
written: list[str] = []
|
|
643
|
+
for key, value in values.items():
|
|
644
|
+
if not isinstance(value, str):
|
|
645
|
+
continue
|
|
646
|
+
envfile.upsert_env_var(key, value, env_path)
|
|
647
|
+
written.append(key)
|
|
648
|
+
if written:
|
|
649
|
+
envfile.ensure_permission_floor(env_path)
|
|
650
|
+
return written
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _run_bundle_import(
|
|
654
|
+
opts: SetupOptions,
|
|
655
|
+
*,
|
|
656
|
+
http: HttpFn,
|
|
657
|
+
auth_header: str,
|
|
658
|
+
repo: Path,
|
|
659
|
+
receipt: dict[str, Any],
|
|
660
|
+
) -> int | None:
|
|
661
|
+
if opts.import_path is None:
|
|
662
|
+
return None
|
|
663
|
+
path = opts.import_path
|
|
664
|
+
if not path.is_file():
|
|
665
|
+
sys.stderr.write(f"devcake setup: --import file not found: {path}\n")
|
|
666
|
+
receipt["ok"] = False
|
|
667
|
+
return 2
|
|
668
|
+
raw = path.read_bytes()
|
|
669
|
+
content_b64 = base64.b64encode(raw).decode()
|
|
670
|
+
body: dict[str, Any] = {
|
|
671
|
+
"content_b64": content_b64,
|
|
672
|
+
"save_as": opts.import_profile
|
|
673
|
+
or f"imported-{path.stem}".replace(" ", "-")[:64],
|
|
674
|
+
"overwrite": opts.import_overwrite,
|
|
675
|
+
}
|
|
676
|
+
if opts.import_passphrase_source:
|
|
677
|
+
try:
|
|
678
|
+
body["passphrase"] = _read_secret(
|
|
679
|
+
opts.import_passphrase_source, label="import-passphrase"
|
|
680
|
+
)
|
|
681
|
+
except UsageError as e:
|
|
682
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
683
|
+
receipt["ok"] = False
|
|
684
|
+
return 2
|
|
685
|
+
|
|
686
|
+
status, imp = _api(
|
|
687
|
+
http, "POST", opts.base_url, "/api/v1/settings/import", body, auth_header
|
|
688
|
+
)
|
|
689
|
+
if status >= 400:
|
|
690
|
+
sys.stderr.write(
|
|
691
|
+
f"devcake setup: settings import failed ({status}): {_detail(imp)}\n"
|
|
692
|
+
)
|
|
693
|
+
receipt["ok"] = False
|
|
694
|
+
return 1
|
|
695
|
+
if not isinstance(imp, dict):
|
|
696
|
+
receipt["ok"] = False
|
|
697
|
+
return 1
|
|
698
|
+
profile = str(imp.get("saved_as") or body["save_as"])
|
|
699
|
+
sections = list(imp.get("sections") or [])
|
|
700
|
+
|
|
701
|
+
# Apply world-swap.
|
|
702
|
+
status, apply_out = _api(
|
|
703
|
+
http,
|
|
704
|
+
"POST",
|
|
705
|
+
opts.base_url,
|
|
706
|
+
f"/api/v1/profiles/{profile}/apply",
|
|
707
|
+
None,
|
|
708
|
+
auth_header,
|
|
709
|
+
)
|
|
710
|
+
if status >= 400:
|
|
711
|
+
sys.stderr.write(
|
|
712
|
+
f"devcake setup: profile apply failed ({status}): {_detail(apply_out)}\n"
|
|
713
|
+
)
|
|
714
|
+
receipt["ok"] = False
|
|
715
|
+
receipt["bundle_import"] = {
|
|
716
|
+
"applied": False,
|
|
717
|
+
"path": str(path),
|
|
718
|
+
"sections": sections,
|
|
719
|
+
"profile": profile,
|
|
720
|
+
"setup_env_keys": [],
|
|
721
|
+
"secret_key_counts": {},
|
|
722
|
+
}
|
|
723
|
+
return 1
|
|
724
|
+
|
|
725
|
+
setup_env_keys: list[str] = []
|
|
726
|
+
secret_key_counts: dict[str, int] = {}
|
|
727
|
+
# Host-side section C via /settings/import/env when present.
|
|
728
|
+
if imp.get("has_setup_env"):
|
|
729
|
+
status, env_body = _api(
|
|
730
|
+
http,
|
|
731
|
+
"POST",
|
|
732
|
+
opts.base_url,
|
|
733
|
+
"/api/v1/settings/import/env",
|
|
734
|
+
{
|
|
735
|
+
"content_b64": content_b64,
|
|
736
|
+
**(
|
|
737
|
+
{"passphrase": body["passphrase"]}
|
|
738
|
+
if "passphrase" in body
|
|
739
|
+
else {}
|
|
740
|
+
),
|
|
741
|
+
},
|
|
742
|
+
auth_header,
|
|
743
|
+
)
|
|
744
|
+
# import/env returns plaintext .env — parse KEY=VALUE and upsert.
|
|
745
|
+
if status < 400 and isinstance(env_body, str):
|
|
746
|
+
values: dict[str, str] = {}
|
|
747
|
+
for line in env_body.splitlines():
|
|
748
|
+
raw_line = line.strip()
|
|
749
|
+
if not raw_line or raw_line.startswith("#"):
|
|
750
|
+
continue
|
|
751
|
+
if "=" not in raw_line:
|
|
752
|
+
continue
|
|
753
|
+
k, _, v = raw_line.partition("=")
|
|
754
|
+
if k:
|
|
755
|
+
values[k] = v
|
|
756
|
+
setup_env_keys = _apply_setup_env_to_host(repo, values)
|
|
757
|
+
if setup_env_keys:
|
|
758
|
+
sections = list(dict.fromkeys([*sections, "setup_env"]))
|
|
759
|
+
receipt["next_steps"].append(
|
|
760
|
+
"devcake up # bundle setup_env changed host .env — compose must reload"
|
|
761
|
+
)
|
|
762
|
+
elif status >= 400:
|
|
763
|
+
sys.stderr.write(
|
|
764
|
+
f"devcake setup: warning: setup_env download failed "
|
|
765
|
+
f"({status}): {_detail(env_body)}\n"
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Secret counts from import response / inventory — names only.
|
|
769
|
+
# inventory() returns {harness: […], connections: […], …} (lists).
|
|
770
|
+
status_inv, inv = _api(
|
|
771
|
+
http, "GET", opts.base_url, "/api/v1/secrets/inventory", None, auth_header
|
|
772
|
+
)
|
|
773
|
+
if status_inv < 400 and isinstance(inv, dict):
|
|
774
|
+
conn = inv.get("connections") or []
|
|
775
|
+
harness = inv.get("harness") or []
|
|
776
|
+
if isinstance(conn, (list, dict)):
|
|
777
|
+
secret_key_counts["connections"] = len(conn)
|
|
778
|
+
if isinstance(harness, (list, dict)):
|
|
779
|
+
secret_key_counts["harness"] = len(harness)
|
|
780
|
+
|
|
781
|
+
receipt["bundle_import"] = {
|
|
782
|
+
"applied": True,
|
|
783
|
+
"path": str(path),
|
|
784
|
+
"sections": sections,
|
|
785
|
+
"profile": profile,
|
|
786
|
+
"setup_env_keys": setup_env_keys,
|
|
787
|
+
"secret_key_counts": secret_key_counts,
|
|
788
|
+
}
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _doctor_into_receipt(
|
|
793
|
+
receipt: dict[str, Any],
|
|
794
|
+
*,
|
|
795
|
+
repo: Path | None,
|
|
796
|
+
) -> bool:
|
|
797
|
+
"""Run doctor catalog into receipt. Returns True when hard-ok."""
|
|
798
|
+
checks = doctor.run_checks(repo_root=repo)
|
|
799
|
+
hard_ok = not any((not c.ok) and c.hard for c in checks)
|
|
800
|
+
receipt["doctor"] = {
|
|
801
|
+
"ok": hard_ok,
|
|
802
|
+
"checks": [{"id": c.id, "ok": c.ok, "detail": c.detail} for c in checks],
|
|
803
|
+
}
|
|
804
|
+
for c in checks:
|
|
805
|
+
if not c.ok:
|
|
806
|
+
# Remedies live in detail; never run them.
|
|
807
|
+
receipt["next_steps"].append(c.detail)
|
|
808
|
+
if not hard_ok:
|
|
809
|
+
receipt["ok"] = False
|
|
810
|
+
return hard_ok
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _empty_receipt() -> dict[str, Any]:
|
|
814
|
+
return {
|
|
815
|
+
"ok": True,
|
|
816
|
+
"schema_version": 1,
|
|
817
|
+
"roles_created": [],
|
|
818
|
+
"roles": {},
|
|
819
|
+
"connections": {"pmo": [], "repos": []},
|
|
820
|
+
"secrets_received": {
|
|
821
|
+
"pmo_api_key": False,
|
|
822
|
+
"repo_token_count": 0,
|
|
823
|
+
"harness_key_count": 0,
|
|
824
|
+
},
|
|
825
|
+
"bundle_import": {},
|
|
826
|
+
"doctor": {},
|
|
827
|
+
"next_steps": [],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _needs_api(opts: SetupOptions) -> bool:
|
|
832
|
+
return (
|
|
833
|
+
_wants_roster(opts)
|
|
834
|
+
or _wants_pmo(opts)
|
|
835
|
+
or _wants_repo(opts)
|
|
836
|
+
or opts.import_path is not None
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def run_setup(
|
|
841
|
+
opts: SetupOptions,
|
|
842
|
+
*,
|
|
843
|
+
http: HttpFn | None = None,
|
|
844
|
+
repo_root: Path | None = None,
|
|
845
|
+
) -> int:
|
|
846
|
+
"""Execute setup slices. Returns ADR-0038 exit code."""
|
|
847
|
+
http_fn = http or default_http
|
|
848
|
+
try:
|
|
849
|
+
repo = repo_root or require_checkout_root()
|
|
850
|
+
except FileNotFoundError as e:
|
|
851
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
852
|
+
return 2
|
|
853
|
+
|
|
854
|
+
receipt = _empty_receipt()
|
|
855
|
+
exit_code = 0
|
|
856
|
+
auth = ""
|
|
857
|
+
|
|
858
|
+
if _needs_api(opts):
|
|
859
|
+
try:
|
|
860
|
+
user, password = _load_admin_auth(repo)
|
|
861
|
+
except RuntimeError as e:
|
|
862
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
863
|
+
receipt["ok"] = False
|
|
864
|
+
_emit(receipt, opts.as_json)
|
|
865
|
+
return 1
|
|
866
|
+
auth = _basic_auth_header(user, password)
|
|
867
|
+
|
|
868
|
+
# Slice order: roster → connections → import → doctor.
|
|
869
|
+
# First-setup 409 locks exit 5 but other upsert/import slices still run.
|
|
870
|
+
for step in (
|
|
871
|
+
lambda: _run_first_setup(
|
|
872
|
+
opts, http=http_fn, auth_header=auth, receipt=receipt
|
|
873
|
+
),
|
|
874
|
+
lambda: _upsert_pmo(
|
|
875
|
+
opts, http=http_fn, auth_header=auth, receipt=receipt
|
|
876
|
+
),
|
|
877
|
+
lambda: _upsert_repo(
|
|
878
|
+
opts, http=http_fn, auth_header=auth, receipt=receipt
|
|
879
|
+
),
|
|
880
|
+
lambda: _run_bundle_import(
|
|
881
|
+
opts, http=http_fn, auth_header=auth, repo=repo, receipt=receipt
|
|
882
|
+
),
|
|
883
|
+
):
|
|
884
|
+
try:
|
|
885
|
+
rc = step()
|
|
886
|
+
except RuntimeError as e:
|
|
887
|
+
sys.stderr.write(f"devcake setup: {e}\n")
|
|
888
|
+
receipt["ok"] = False
|
|
889
|
+
_emit(receipt, opts.as_json)
|
|
890
|
+
return 1
|
|
891
|
+
if rc is not None and exit_code == 0:
|
|
892
|
+
exit_code = rc
|
|
893
|
+
|
|
894
|
+
hard_ok = _doctor_into_receipt(receipt, repo=repo)
|
|
895
|
+
if exit_code == 0 and not hard_ok:
|
|
896
|
+
exit_code = 3
|
|
897
|
+
if exit_code != 0:
|
|
898
|
+
receipt["ok"] = False
|
|
899
|
+
|
|
900
|
+
if not receipt.get("bundle_import"):
|
|
901
|
+
receipt.pop("bundle_import", None)
|
|
902
|
+
|
|
903
|
+
_emit(receipt, opts.as_json)
|
|
904
|
+
return exit_code
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _emit(receipt: dict[str, Any], as_json: bool) -> None:
|
|
908
|
+
if as_json:
|
|
909
|
+
sys.stdout.write(json.dumps(receipt, indent=2) + "\n")
|
|
910
|
+
return
|
|
911
|
+
# Human summary — never secret values.
|
|
912
|
+
lines = ["devcake setup"]
|
|
913
|
+
if receipt.get("roles_created"):
|
|
914
|
+
lines.append(f" roles_created: {', '.join(receipt['roles_created'])}")
|
|
915
|
+
elif receipt.get("roles"):
|
|
916
|
+
lines.append(" roles: (not created — see conflict / next_steps)")
|
|
917
|
+
conns = receipt.get("connections") or {}
|
|
918
|
+
for p in conns.get("pmo") or []:
|
|
919
|
+
lines.append(f" pmo: {p.get('name')} configured={p.get('configured')}")
|
|
920
|
+
for r in conns.get("repos") or []:
|
|
921
|
+
lines.append(f" repo: {r.get('name')} configured={r.get('configured')}")
|
|
922
|
+
bi = receipt.get("bundle_import") or {}
|
|
923
|
+
if bi:
|
|
924
|
+
lines.append(
|
|
925
|
+
f" bundle_import: applied={bi.get('applied')} profile={bi.get('profile')}"
|
|
926
|
+
)
|
|
927
|
+
doc = receipt.get("doctor") or {}
|
|
928
|
+
if doc:
|
|
929
|
+
lines.append(f" doctor: ok={doc.get('ok')}")
|
|
930
|
+
for step in receipt.get("next_steps") or []:
|
|
931
|
+
lines.append(f" next: {step}")
|
|
932
|
+
lines.append(" ok" if receipt.get("ok") else " FAILED")
|
|
933
|
+
sys.stdout.write("\n".join(lines) + "\n")
|