mkctl 0.4.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.
mkctl/commands.py ADDED
@@ -0,0 +1,527 @@
1
+ """What each command does, once there is a login to do it with.
2
+
3
+ `C14`. Every function here is one or two calls against endpoints that already existed:
4
+ this is the CLI catching up with the API, not new platform behaviour. Where a command
5
+ looks like it is doing something clever it is doing the opposite — resolving a name the
6
+ caller knows into an id the API wants, and nothing else.
7
+
8
+ Two decisions worth stating, because both could reasonably have gone the other way.
9
+
10
+ **A bundle is pushed, not built.** `mkbundle build` produces the archive and this uploads
11
+ it. Building needs a connector catalog, `mklang check` on every machine, and a signing
12
+ key; folding that in would make one command that fails for eight unrelated reasons and
13
+ would put a signing key in the same process as a login token.
14
+
15
+ **Inputs are strings unless the caller says otherwise.** `--input thread_id=t-1` sends a
16
+ string, and `--input-json` is how anything else is expressed. A shell word carries no
17
+ type, so guessing one — `count=2` as a number here and a string somewhere else — would
18
+ make the same command mean two things depending on what the value looked like.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from mkp_contracts.archive import manifest_of
30
+ from mkp_contracts.errors import ContractError
31
+
32
+ from .client import ControlPlane, ControlPlaneError
33
+
34
+ DASH = "-"
35
+
36
+
37
+ def _scope_of(binding: dict) -> str:
38
+ """Where a binding applies, as one readable phrase."""
39
+ if binding.get("environment"):
40
+ return f"{binding['project']}/{binding['environment']}"
41
+ if binding.get("project"):
42
+ return f"project {binding['project']}"
43
+ return "the whole tenant"
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Who am I.
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def whoami(plane: ControlPlane, args: argparse.Namespace) -> int:
52
+ body = plane.get("/v1/me")
53
+ if not _as_json(args, body):
54
+ print(f"principal {body['principal_id']} ({body['kind']})")
55
+ print(f"tenant {body['tenant_id']}")
56
+ # `roles` would print the same line for a role over the whole tenant and a role
57
+ # over one project, and the second one is refused by every tenant-scoped endpoint
58
+ # (`D14`). One line per binding, with where it applies.
59
+ for binding in body.get("bindings") or []:
60
+ print(f"role {binding['role']} on {_scope_of(binding)}")
61
+ if not body.get("roles"):
62
+ # Being authenticated is not being authorized, and a user with no binding can
63
+ # do nothing at all. Saying so here saves a confusing round of 403s.
64
+ _warn("no role binding: you are authenticated but not authorized for anything yet")
65
+ return 0
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Bundles and the pointer.
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def bundle_push(plane: ControlPlane, args: argparse.Namespace) -> int:
74
+ """Register the manifest, then upload the bytes.
75
+
76
+ Two calls because they are two facts (`routes.py`), and in this order because the
77
+ upload is refused for a bundle nobody registered. The digest is the server's answer
78
+ to the first call — see `client.py` for why this does not compute its own.
79
+ """
80
+ archive = Path(args.archive)
81
+ payload = archive.read_bytes()
82
+ try:
83
+ manifest = manifest_of(payload)
84
+ except (ContractError, KeyError, ValueError) as exc:
85
+ raise ControlPlaneError(0, f"{archive}: not a bundle archive — {exc}") from exc
86
+
87
+ registered = plane.post(f"/v1/projects/{args.project}/bundles", json={"manifest": manifest})
88
+ digest = registered["digest"]
89
+ plane.put(f"/v1/projects/{args.project}/bundles/{digest}/archive", content=payload)
90
+
91
+ if not _as_json(args, {"digest": digest, "project": args.project, "bytes": len(payload)}):
92
+ print(f"registered {digest}")
93
+ print(f"uploaded {_size(len(payload))}")
94
+ signature = manifest.get("signature")
95
+ # Not an error here: the control plane accepts an unsigned bundle and the runner
96
+ # refuses one (`D2`). Saying so at push time is the difference between finding out
97
+ # now and finding out one poll later, on a runner, as a run that will not start.
98
+ if not signature:
99
+ _warn("this bundle is unsigned, and a runner refuses what it cannot verify (D2)")
100
+ return 0
101
+
102
+
103
+ def deploy(plane: ControlPlane, args: argparse.Namespace) -> int:
104
+ body = plane.post(
105
+ f"/v1/projects/{args.project}/environments/{args.environment}/deployments",
106
+ json={"bundle_digest": args.digest},
107
+ )
108
+ if not _as_json(args, body):
109
+ print(f"deployed {_short(args.digest)} to {args.project}/{args.environment}")
110
+ return 0
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Projects and environments.
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def project_list(plane: ControlPlane, args: argparse.Namespace) -> int:
119
+ rows = plane.get("/v1/projects")
120
+ if not _as_json(args, rows):
121
+ _table(["PROJECT", "ID"], [[row["slug"], row["id"]] for row in rows])
122
+ return 0
123
+
124
+
125
+ def project_create(plane: ControlPlane, args: argparse.Namespace) -> int:
126
+ body = plane.post("/v1/projects", json={"slug": args.slug})
127
+ if not _as_json(args, body):
128
+ print(f"created project {body['slug']}")
129
+ return 0
130
+
131
+
132
+ def environment_list(plane: ControlPlane, args: argparse.Namespace) -> int:
133
+ rows = plane.get(f"/v1/projects/{args.project}/environments")
134
+ if not _as_json(args, rows):
135
+ _table(
136
+ ["ENVIRONMENT", "DEPLOYED"],
137
+ [[row["name"], _short(row["bundle_digest"])] for row in rows],
138
+ )
139
+ return 0
140
+
141
+
142
+ def environment_create(plane: ControlPlane, args: argparse.Namespace) -> int:
143
+ body = plane.post(f"/v1/projects/{args.project}/environments", json={"name": args.name})
144
+ if not _as_json(args, body):
145
+ print(f"created environment {args.project}/{body['name']}, pointing at nothing")
146
+ return 0
147
+
148
+
149
+ def environment_get(plane: ControlPlane, args: argparse.Namespace) -> int:
150
+ body = plane.get(f"/v1/projects/{args.project}/environments/{args.name}")
151
+ if not _as_json(args, body):
152
+ print(f"environment {args.project}/{body['environment']}")
153
+ print(f"bundle {body['bundle_digest'] or DASH}")
154
+ print(f"config {body['config_digest'] or DASH}")
155
+ return 0
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Runs, including the one that is waiting.
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def run_start(plane: ControlPlane, args: argparse.Namespace) -> int:
164
+ inputs = _inputs(args)
165
+ body = plane.post(
166
+ f"/v1/projects/{args.project}/environments/{args.environment}/runs",
167
+ json={"workflow": args.workflow, "inputs": inputs},
168
+ )
169
+ if not _as_json(args, body):
170
+ print(f"run {body['id']}")
171
+ print(f"workflow {body['workflow']} on {_short(body['bundle_digest'])}")
172
+ print(f"status {body['status']}")
173
+ # Nothing executes in that request (ADR 0002), and a person who has just been
174
+ # handed a run id will otherwise read `pending` as a failure to start.
175
+ print("\nNothing runs in this request: a runner picks it up on its next poll.")
176
+ # `D16`: the API says at creation when that poll is never coming.
177
+ for warning in body.get("warnings", []):
178
+ print(f"warning: {warning}", file=sys.stderr)
179
+ return 0
180
+
181
+
182
+ def run_get(plane: ControlPlane, args: argparse.Namespace) -> int:
183
+ body = plane.get(f"/v1/runs/{args.run_id}")
184
+ if not _as_json(args, body):
185
+ print(f"run {body['id']}")
186
+ print(f"workflow {body['workflow']}")
187
+ if body.get("project") and body.get("environment"):
188
+ print(f"where {body['project']}/{body['environment']}")
189
+ print(f"status {body['status']}")
190
+ if body.get("error"):
191
+ print(f"error {body['error']}")
192
+ waiting = body.get("waiting_for")
193
+ if waiting:
194
+ summary = waiting.get("summary") or f"step {waiting['step_id']}"
195
+ print(f"waiting {summary} since {waiting['since']}")
196
+ pending = body.get("pending_reply")
197
+ if pending and pending.get("kind") == "abandon":
198
+ print("pending abandon (runner not yet cancelled the durable run)")
199
+ elif pending:
200
+ print(f"pending reply at {pending['step_id']} (runner not yet delivered)")
201
+ else:
202
+ print(f"\nAnswer: mkctl run reply {body['id']} --text '…'")
203
+ print(f"Abandon: mkctl run abandon {body['id']}")
204
+ if body["steps"]:
205
+ print()
206
+ _table(
207
+ ["STEP", "TRY", "STATUS", "ERROR"],
208
+ [
209
+ [
210
+ step["step_id"],
211
+ str(step["attempt"]),
212
+ step["status"],
213
+ step["error_code"] or DASH,
214
+ ]
215
+ for step in body["steps"]
216
+ ],
217
+ )
218
+ return 0
219
+
220
+
221
+ def run_list(plane: ControlPlane, args: argparse.Namespace) -> int:
222
+ """Cursor-paginated runs (`GET /v1/runs`, C25)."""
223
+ params: dict[str, str] = {}
224
+ if args.status:
225
+ params["status"] = args.status
226
+ if args.project:
227
+ params["project"] = args.project
228
+ if args.environment:
229
+ params["environment"] = args.environment
230
+ if args.workflow:
231
+ params["workflow"] = args.workflow
232
+ if args.limit is not None:
233
+ params["limit"] = str(args.limit)
234
+ if args.cursor:
235
+ params["cursor"] = args.cursor
236
+ query = "&".join(f"{k}={v}" for k, v in params.items())
237
+ path = f"/v1/runs?{query}" if query else "/v1/runs"
238
+ body = plane.get(path)
239
+ if not _as_json(args, body):
240
+ items = body.get("items") or []
241
+ _table(
242
+ ["ID", "STATUS", "WORKFLOW", "WHERE", "WAITING"],
243
+ [
244
+ [
245
+ row["id"],
246
+ row["status"],
247
+ row["workflow"],
248
+ f"{row.get('project') or '?'}/{row.get('environment') or '?'}",
249
+ (row.get("waiting_for") or {}).get("summary")
250
+ or (row.get("waiting_for") or {}).get("step_id")
251
+ or DASH,
252
+ ]
253
+ for row in items
254
+ ],
255
+ )
256
+ if body.get("next_cursor"):
257
+ print(f"\nnext page: mkctl run list --cursor {body['next_cursor']}")
258
+ return 0
259
+
260
+
261
+ def run_reply(plane: ControlPlane, args: argparse.Namespace) -> int:
262
+ """Answer a waiting run.
263
+
264
+ `--text` covers what an escalation actually asks for — a sentence a machine reads as
265
+ prose — and `--json-payload` is there because the gate's answer is whatever the
266
+ workflow reads, not necessarily a string.
267
+ """
268
+ payload: Any = args.text
269
+ if args.json_payload is not None:
270
+ try:
271
+ payload = json.loads(args.json_payload)
272
+ except json.JSONDecodeError as exc:
273
+ raise ControlPlaneError(0, f"--json-payload is not JSON: {exc}") from exc
274
+ request: dict[str, Any] = {"payload": payload}
275
+ if args.step is not None:
276
+ request["step_id"] = args.step
277
+
278
+ body = plane.post(f"/v1/runs/{args.run_id}/reply", json=request)
279
+ if not _as_json(args, body):
280
+ print(f"answered {body['run_id']} at step {body['step_id']}")
281
+ # The reply is a row and the control plane holds no runner address (ADR 0002),
282
+ # so "answered" is not "the durable run woke" and saying otherwise would be a
283
+ # lie a person would then wait on.
284
+ print("The runner collects it on its next poll.")
285
+ return 0
286
+
287
+
288
+ def run_abandon(plane: ControlPlane, args: argparse.Namespace) -> int:
289
+ """Abandon a waiting run (`POST /v1/runs/{id}/abandon`, C28)."""
290
+ request: dict[str, Any] = {}
291
+ if args.reason:
292
+ request["reason"] = args.reason
293
+ body = plane.post(
294
+ f"/v1/runs/{args.run_id}/abandon",
295
+ json=request if request else {},
296
+ )
297
+ if not _as_json(args, body):
298
+ print(f"abandon queued for {body['run_id']} at step {body['step_id']}")
299
+ print("The runner cancels the durable workflow on its next poll.")
300
+ return 0
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Who is in a tenant, and what they may do.
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def user_list(plane: ControlPlane, args: argparse.Namespace) -> int:
309
+ rows = plane.get("/v1/users")
310
+ if not _as_json(args, rows):
311
+ _table(
312
+ ["EMAIL", "SUBJECT", "STATUS", "ROLES"],
313
+ [
314
+ [
315
+ row["email"],
316
+ row["external_subject"],
317
+ row["status"],
318
+ # Role *and* scope: "who can deploy" is the question this table is
319
+ # read for, and `maintainer` alone does not answer it (`D14`).
320
+ "; ".join(
321
+ f"{binding['role']} on {_scope_of(binding)}"
322
+ for binding in row.get("bindings") or []
323
+ )
324
+ or DASH,
325
+ ]
326
+ for row in rows
327
+ ],
328
+ )
329
+ return 0
330
+
331
+
332
+ def user_create(plane: ControlPlane, args: argparse.Namespace) -> int:
333
+ body = plane.post("/v1/users", json={"external_subject": args.subject, "email": args.email})
334
+ if not _as_json(args, body):
335
+ print(f"created user {body['email']} ({body['id']})")
336
+ # Membership and permission are two decisions in the API (`routes_resources.py`),
337
+ # and a user created here can do nothing until the second one is made.
338
+ print("No roles yet: grant one with `mkctl role grant`.")
339
+ return 0
340
+
341
+
342
+ def role_grant(plane: ControlPlane, args: argparse.Namespace) -> int:
343
+ user_id = _resolve_user(plane, args.user)
344
+ body: dict[str, Any] = {"role": args.role}
345
+ if args.project is not None:
346
+ body["project"] = args.project
347
+ if args.environment is not None:
348
+ body["environment"] = args.environment
349
+ plane.put(f"/v1/users/{user_id}/roles", json=body)
350
+ if not _as_json(args, {"user_id": user_id, **body}):
351
+ print(f"granted {args.role} to {args.user} at {_scope(args)}")
352
+ return 0
353
+
354
+
355
+ def role_revoke(plane: ControlPlane, args: argparse.Namespace) -> int:
356
+ user_id = _resolve_user(plane, args.user)
357
+ params = {
358
+ name: value
359
+ for name, value in (("project", args.project), ("environment", args.environment))
360
+ if value is not None
361
+ }
362
+ plane.delete(f"/v1/users/{user_id}/roles/{args.role}", params=params or None)
363
+ if not _as_json(args, {"user_id": user_id, "role": args.role, **params}):
364
+ print(f"revoked {args.role} from {args.user} at {_scope(args)}")
365
+ return 0
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Tenants, which is the one thing that acts above a tenant.
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ def tenant_list(plane: ControlPlane, args: argparse.Namespace) -> int:
374
+ rows = plane.get("/v1/tenants")
375
+ if not _as_json(args, rows):
376
+ _table(
377
+ ["TENANT", "STATUS", "CREATED"],
378
+ [[row["slug"], row["status"], row["created_at"][:10]] for row in rows],
379
+ )
380
+ return 0
381
+
382
+
383
+ def tenant_create(plane: ControlPlane, args: argparse.Namespace) -> int:
384
+ """Create a tenant and its first owner.
385
+
386
+ Needs the platform grant, which is not a role: `mkdb grant-platform-admin` writes it
387
+ and `deploy/runbooks/onboarding.md` is the procedure. An operator who owns everything
388
+ inside a tenant still gets a 403 here, which is the point of the separation.
389
+ """
390
+ payload: dict = {
391
+ "slug": args.slug,
392
+ "owner": {"external_subject": args.owner_subject, "email": args.owner_email},
393
+ }
394
+ if args.monthly_run_limit is not None:
395
+ payload["monthly_run_limit"] = args.monthly_run_limit
396
+ if args.monthly_token_limit is not None:
397
+ payload["monthly_token_limit"] = args.monthly_token_limit
398
+ body = plane.post("/v1/tenants", json=payload)
399
+ if not _as_json(args, body):
400
+ owner = body["owner"]
401
+ print(f"created tenant {body['slug']} ({body['id']})")
402
+ print(f"owner {owner['email']} — {', '.join(owner['roles'])}")
403
+ return 0
404
+
405
+
406
+ def connection_list(plane: ControlPlane, args: argparse.Namespace) -> int:
407
+ rows = plane.get("/v1/connections")
408
+ if not _as_json(args, rows):
409
+ _table(
410
+ ["LABEL", "CONNECTOR", "STATUS"],
411
+ [[row["label"], row["connector"], row["status"]] for row in rows],
412
+ )
413
+ return 0
414
+
415
+
416
+ def connection_create(plane: ControlPlane, args: argparse.Namespace) -> int:
417
+ """Store a static credential, or an authless row (`D10`) when material is omitted.
418
+
419
+ Material is always a map of strings from `--material name=value`. No pairs means
420
+ empty material — the honest shape for `auth: none` connectors that still need a
421
+ bound name for deploy rules.
422
+ """
423
+ material: dict[str, str] = {}
424
+ for pair in args.material or []:
425
+ name, separator, value = pair.partition("=")
426
+ if not separator:
427
+ raise ControlPlaneError(400, f"--material expects name=value, got {pair!r} (no '=')")
428
+ material[name] = value
429
+ body = plane.post(
430
+ "/v1/connections",
431
+ json={"connector": args.connector, "label": args.label, "material": material},
432
+ )
433
+ if not _as_json(args, body):
434
+ kind = "authless" if not material else "static"
435
+ print(f"created connection {body['label']} ({body['id']}) — {kind}, {body['status']}")
436
+ return 0
437
+
438
+
439
+ def connection_bind(plane: ControlPlane, args: argparse.Namespace) -> int:
440
+ """Name what a workflow's connection: line means in one environment."""
441
+ plane.put(
442
+ f"/v1/projects/{args.project}/environments/{args.environment}/connections/{args.name}",
443
+ json={"connection_id": args.connection_id},
444
+ )
445
+ if not getattr(args, "json", False):
446
+ print(f"bound {args.name} → {args.connection_id} in {args.project}/{args.environment}")
447
+ return 0
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Helpers.
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ def _resolve_user(plane: ControlPlane, who: str) -> str:
456
+ """A uuid, or the email of somebody in this tenant.
457
+
458
+ A person knows the address they invited; the id is in a response they have already
459
+ closed. Resolving here rather than making them look it up is the same argument
460
+ `RoleIn` makes about scopes — the lookup is what an API is for.
461
+ """
462
+ if "@" not in who:
463
+ return who
464
+ for user in plane.get("/v1/users"):
465
+ if user["email"] == who:
466
+ return str(user["id"])
467
+ raise ControlPlaneError(404, f"no user with the address {who} in this tenant")
468
+
469
+
470
+ def _inputs(args: argparse.Namespace) -> dict[str, Any]:
471
+ inputs: dict[str, Any] = {}
472
+ for pair in args.input or []:
473
+ name, separator, value = pair.partition("=")
474
+ if not separator:
475
+ raise ControlPlaneError(0, f"--input takes name=value, not {pair!r}")
476
+ inputs[name] = value
477
+ if args.input_json is not None:
478
+ try:
479
+ decoded = json.loads(args.input_json)
480
+ except json.JSONDecodeError as exc:
481
+ raise ControlPlaneError(0, f"--input-json is not JSON: {exc}") from exc
482
+ if not isinstance(decoded, dict):
483
+ raise ControlPlaneError(0, "--input-json must be a JSON object of workflow inputs")
484
+ inputs.update(decoded)
485
+ return inputs
486
+
487
+
488
+ def _scope(args: argparse.Namespace) -> str:
489
+ if args.environment is not None:
490
+ return f"{args.project}/{args.environment}"
491
+ if args.project is not None:
492
+ return f"project {args.project}"
493
+ return "the tenant"
494
+
495
+
496
+ def _as_json(args: argparse.Namespace, payload: Any) -> bool:
497
+ if not getattr(args, "json", False):
498
+ return False
499
+ print(json.dumps(payload, indent=2, default=str))
500
+ return True
501
+
502
+
503
+ def _table(headers: list[str], rows: list[list[str]]) -> None:
504
+ if not rows:
505
+ print("(none)")
506
+ return
507
+ widths = [
508
+ max(len(headers[column]), *(len(row[column]) for row in rows))
509
+ for column in range(len(headers))
510
+ ]
511
+ print(" ".join(header.ljust(widths[i]) for i, header in enumerate(headers)).rstrip())
512
+ for row in rows:
513
+ print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
514
+
515
+
516
+ def _short(digest: str | None) -> str:
517
+ return digest[:12] if digest else DASH
518
+
519
+
520
+ def _size(count: int) -> str:
521
+ if count < 1024:
522
+ return f"{count} bytes"
523
+ return f"{count / 1024:.1f} KiB"
524
+
525
+
526
+ def _warn(message: str) -> None:
527
+ print(f"\n{message}", file=sys.stderr)
mkctl/config.py ADDED
@@ -0,0 +1,59 @@
1
+ """Where `mkctl` remembers how to reach a platform (`C32`).
2
+
3
+ One file, three values, and a strict precedence: an explicit flag beats the
4
+ environment, the environment beats this file, and this file beats asking the control
5
+ plane. Only *discovered* values are ever written here — what a person set in a flag
6
+ or a shell profile is theirs to manage, and a tool that copies it into a second
7
+ location is a tool that creates drift between two places that both look
8
+ authoritative. The day this module was written, the issuer and client id had changed
9
+ under every shell profile that had them: discovery is the fix, this file is only its
10
+ cache.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import tomllib
16
+ from pathlib import Path
17
+
18
+
19
+ def path() -> Path:
20
+ import os
21
+
22
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
23
+ return Path(base) / "mkctl" / "config.toml"
24
+
25
+
26
+ def load() -> dict[str, str]:
27
+ try:
28
+ raw = path().read_bytes()
29
+ except FileNotFoundError:
30
+ return {}
31
+ try:
32
+ data = tomllib.loads(raw.decode())
33
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError):
34
+ # A corrupt cache is an empty cache, never a crash: everything in it can be
35
+ # re-discovered from the control plane it points at.
36
+ return {}
37
+ return {k: v for k, v in data.items() if isinstance(v, str)}
38
+
39
+
40
+ def save(control_plane: str, issuer: str, client_id: str) -> None:
41
+ update(control_plane=control_plane, issuer=issuer, client_id=client_id)
42
+
43
+
44
+ def update(**values: str) -> None:
45
+ """Merge into the file, keeping what is already there.
46
+
47
+ One writer shape for every key — the discovered endpoints and the advisory
48
+ bookkeeping (ADR 0014: `latest_seen`, `advised_at`) share the file because they
49
+ share the nature: facts the platform declared, safe to lose, re-learned on the
50
+ next login.
51
+ """
52
+ merged = {**load(), **values}
53
+ target = path()
54
+ target.parent.mkdir(parents=True, exist_ok=True)
55
+ lines = "".join(f'{key} = "{value}"\n' for key, value in sorted(merged.items()))
56
+ target.write_text(
57
+ "# Written by `mkctl login` from GET /v1/cli-config — a cache of what the\n"
58
+ "# control plane declared, safe to delete: login discovers it again.\n" + lines
59
+ )