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