xmemcli 0.0.1__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.
@@ -0,0 +1,769 @@
1
+ """Instance lifecycle and XMD/schema workflow commands for ``xmemcli``.
2
+
3
+ Imported lazily from ``xmem._build_parser`` so ``xmem`` finishes loading before we bind helpers.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+ from urllib.parse import urlencode
13
+
14
+ from ._output import CliOutput, OutputKind, make_error
15
+ from ._xmem_bridge import curate_instance, http_to_exit, xmem_module
16
+
17
+ try:
18
+ import yaml as _yaml
19
+ except ImportError:
20
+ _yaml = None
21
+
22
+
23
+ def _yaml_dump_or_json(value: Any) -> str:
24
+ if _yaml is not None:
25
+ return _yaml.safe_dump(value, sort_keys=False, default_flow_style=False)
26
+ return json.dumps(value, indent=2) + "\n"
27
+
28
+
29
+ def _infer_schema_type(path: str | None, explicit: str | None) -> str:
30
+ if explicit:
31
+ return explicit
32
+ if path and Path(path).suffix.lower() == ".json":
33
+ return "json"
34
+ return "yaml"
35
+
36
+
37
+ def _build_instance_schema_payload(schema_text: str, schema_type: str) -> dict[str, Any]:
38
+ if schema_type == "json":
39
+ return {"json_schema": {"value": schema_text}}
40
+ return {"yml": {"value": schema_text}}
41
+
42
+
43
+ def _extract_limit_message(body: str) -> tuple[str, str] | None:
44
+ try:
45
+ data = json.loads(body)
46
+ err = (data.get("errors") or [{}])[0]
47
+ if err.get("console_url"):
48
+ return (err.get("message") or "instance limit reached"), err["console_url"]
49
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError):
50
+ # Envelope shape varies across API versions; ignore malformed bodies.
51
+ pass
52
+ return None
53
+
54
+
55
+ def _resolve_instance_id(args: argparse.Namespace) -> str | None:
56
+ m = xmem_module()
57
+ explicit = getattr(args, "instance_id_arg", None)
58
+ if explicit:
59
+ return explicit
60
+ if args.instance_id:
61
+ return args.instance_id
62
+ return os.environ.get(m.ENV_INSTANCE_ID)
63
+
64
+
65
+ def _require_instance_id(args: argparse.Namespace) -> tuple[str | None, tuple[CliOutput, int] | None]:
66
+ m = xmem_module()
67
+ instance_id = _resolve_instance_id(args)
68
+ if not instance_id:
69
+ return (
70
+ None,
71
+ (
72
+ make_error(
73
+ f"instance id required (positional arg or ${m.ENV_INSTANCE_ID})",
74
+ stage="usage",
75
+ ),
76
+ m.EXIT_USAGE,
77
+ ),
78
+ )
79
+ return instance_id, None
80
+
81
+
82
+ def _read_schema_mutation_payload(
83
+ args: argparse.Namespace,
84
+ ) -> tuple[dict[str, Any] | None, tuple[CliOutput, int] | None]:
85
+ if not args.schema_file:
86
+ return None, (make_error("--schema-file is required", stage="usage"), xmem_module().EXIT_USAGE)
87
+ try:
88
+ schema_text = Path(args.schema_file).read_text(encoding="utf-8")
89
+ except OSError as exc:
90
+ return None, (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
91
+ schema_type = _infer_schema_type(args.schema_file, getattr(args, "schema_type", None))
92
+ payload: dict[str, Any] = {
93
+ "instance_schema": _build_instance_schema_payload(schema_text, schema_type),
94
+ "confirm_destructive": bool(getattr(args, "confirm_destructive", False)),
95
+ }
96
+ migration_plan = getattr(args, "migration_plan", None)
97
+ if migration_plan:
98
+ try:
99
+ payload["migration_plan"] = json.loads(Path(migration_plan).read_text(encoding="utf-8"))
100
+ except (OSError, json.JSONDecodeError) as exc:
101
+ return None, (make_error(f"cannot read migration_plan: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
102
+ return payload, None
103
+
104
+
105
+ def _parse_schema_mutation_error_body(body: str) -> tuple[str, str, dict[str, Any]]:
106
+ """Return (error_type, message, details) from a schema apply/dry-run 422 body."""
107
+ try:
108
+ data = json.loads(body)
109
+ except json.JSONDecodeError:
110
+ return "", body, {}
111
+
112
+ if isinstance(data, dict) and data.get("error_type"):
113
+ raw_details = data.get("details")
114
+ details: dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
115
+ return (
116
+ str(data.get("error_type") or ""),
117
+ str(data.get("error_message") or data.get("message") or body),
118
+ details,
119
+ )
120
+
121
+ err = (data.get("errors") or [{}])[0] if isinstance(data, dict) else {}
122
+ if isinstance(err, dict):
123
+ raw_details = err.get("details")
124
+ details = raw_details if isinstance(raw_details, dict) else {}
125
+ return (
126
+ str(err.get("code") or err.get("error_type") or ""),
127
+ str(err.get("message") or err.get("error_message") or body),
128
+ details,
129
+ )
130
+ return "", body, {}
131
+
132
+
133
+ class _SchemaApiError(Protocol):
134
+ status: int
135
+ body: str
136
+
137
+
138
+ def _handle_schema_mutation_error(e: _SchemaApiError, *, instance_id: str) -> tuple[CliOutput, int]:
139
+ m = xmem_module()
140
+ if e.status == 422:
141
+ error_type, msg, details = _parse_schema_mutation_error_body(e.body)
142
+ code = error_type.lower()
143
+
144
+ if code == "non_additive_change_requires_plan":
145
+ return (
146
+ make_error(
147
+ f"non-additive change requires --migration-plan "
148
+ f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
149
+ status=e.status,
150
+ stage="plan_required",
151
+ ),
152
+ m.EXIT_HTTP_ERROR,
153
+ )
154
+ if code == "plan_validation_failed":
155
+ detail_bits: list[str] = []
156
+ for key in ("uncovered_non_additive", "uncovered_additive_ops", "missing_additive_ops"):
157
+ items = details.get(key)
158
+ if isinstance(items, list) and items:
159
+ detail_bits.append(f"{key}={len(items)}")
160
+ suffix = f" ({', '.join(detail_bits)})" if detail_bits else ""
161
+ return (
162
+ make_error(f"{msg}{suffix}", status=e.status, stage="validate"),
163
+ m.EXIT_HTTP_ERROR,
164
+ )
165
+ if code == "destructive_confirmation_required" or (
166
+ "destructive" in code and "confirmation" in code
167
+ ):
168
+ return (
169
+ make_error(
170
+ "destructive change requires --confirm-destructive",
171
+ status=e.status,
172
+ stage="destructive",
173
+ ),
174
+ m.EXIT_HTTP_ERROR,
175
+ )
176
+ # Legacy envelopes: only map explicit plan-required codes, not substrings in details.
177
+ if code in ("non_additive", "non_additive_change") or (
178
+ not error_type and "non_additive_change_requires" in msg.lower()
179
+ ):
180
+ return (
181
+ make_error(
182
+ f"non-additive change requires --migration-plan "
183
+ f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
184
+ status=e.status,
185
+ stage="plan_required",
186
+ ),
187
+ m.EXIT_HTTP_ERROR,
188
+ )
189
+ if "destructive" in code or (not error_type and "destructive" in msg.lower()):
190
+ return (
191
+ make_error(
192
+ "destructive change requires --confirm-destructive",
193
+ status=e.status,
194
+ stage="destructive",
195
+ ),
196
+ m.EXIT_HTTP_ERROR,
197
+ )
198
+ return (make_error(msg, status=e.status, stage="validate"), m.EXIT_HTTP_ERROR)
199
+ if e.status == 404:
200
+ return (
201
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
202
+ m.EXIT_HTTP_ERROR,
203
+ )
204
+ if e.status in (401, 403):
205
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
206
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
207
+
208
+
209
+ def _get_with_query(url: str, api_key: str, params: dict[str, Any], *, timeout_s: float = 30.0) -> dict[str, Any]:
210
+ m = xmem_module()
211
+ query = urlencode({k: v for k, v in params.items() if v is not None})
212
+ full_url = f"{url}?{query}" if query else url
213
+ return m._get(full_url, api_key, timeout_s=timeout_s)
214
+
215
+
216
+ def cmd_instance_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
217
+ m = xmem_module()
218
+ api_key, _ = m._require(args, need_instance=False)
219
+ base = m._api_url(args)
220
+ instance_id = args.instance_id_arg
221
+
222
+ explicit_cluster = getattr(args, "cluster", None)
223
+ if explicit_cluster:
224
+ cluster_ids = [explicit_cluster]
225
+ else:
226
+ try:
227
+ clusters_env = m._get(f"{base}/clusters?verbose=true", api_key)
228
+ except m.ApiError as e:
229
+ return (make_error(f"/clusters failed: {e}", status=e.status), http_to_exit(e.status))
230
+ cluster_ids = [c["id"] for c in (clusters_env.get("items") or []) if isinstance(c, dict) and c.get("id")]
231
+
232
+ last_auth_error: m.ApiError | None = None
233
+ for cid in cluster_ids:
234
+ try:
235
+ envelope = m._get(f"{base}/clusters/{cid}/instances/{instance_id}", api_key)
236
+ except m.ApiError as e:
237
+ if e.status == 404:
238
+ continue
239
+ if e.status in (401, 403):
240
+ last_auth_error = e
241
+ continue
242
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
243
+ item = m._unwrap_items(envelope)
244
+ return (CliOutput(OutputKind.INSTANCE_GET, curate_instance(item, args.verbose)), m.EXIT_OK)
245
+
246
+ if last_auth_error is not None:
247
+ return (
248
+ make_error(
249
+ f"instance {instance_id} exists in a cluster this key cannot access",
250
+ status=last_auth_error.status,
251
+ stage="auth",
252
+ ),
253
+ m.EXIT_AUTH_ERROR,
254
+ )
255
+ return (
256
+ make_error(
257
+ f"instance {instance_id} not found in any cluster visible to this key",
258
+ status=404,
259
+ stage="request",
260
+ ),
261
+ m.EXIT_HTTP_ERROR,
262
+ )
263
+
264
+
265
+ def _require_interactive_yes(prompt: str) -> tuple[bool, str | None]:
266
+ """Return (True, None) when the user typed ``yes`` on a real TTY."""
267
+ if not sys.stdin.isatty():
268
+ return False, "instance delete requires an interactive terminal; type yes when prompted (do not pipe yes)"
269
+ try:
270
+ # DELIBERATELY print: confirmation prompt must not pollute stdout in machine mode.
271
+ print(prompt, end="", file=sys.stderr, flush=True)
272
+ answer = input().strip().lower()
273
+ except EOFError:
274
+ return False, "instance delete aborted (confirmation required; type yes)"
275
+ if answer != "yes":
276
+ return False, "instance delete aborted (confirmation required; type yes)"
277
+ return True, None
278
+
279
+
280
+ def cmd_instance_delete(args: argparse.Namespace) -> tuple[CliOutput, int]:
281
+ """Delete an instance after interactive confirmation.
282
+
283
+ Partial by design: human operators at a real TTY must type ``yes``; piped or
284
+ non-interactive stdin is refused. Agents, harnesses, and automation must use
285
+ ``DELETE /instances/{id}`` over HTTP instead — see ``cli/HARNESS-FIRST-TEST.md``
286
+ step 41.
287
+ """
288
+ m = xmem_module()
289
+ api_key, _ = m._require(args, need_instance=False)
290
+ base = m._api_url(args)
291
+
292
+ explicit_id = getattr(args, "instance_id_arg", None)
293
+ if explicit_id:
294
+ instance_id = str(explicit_id)
295
+ else:
296
+ _, resolved = m._require(args, need_instance=True)
297
+ instance_id = str(resolved)
298
+
299
+ name = instance_id
300
+ try:
301
+ lookup = m._get(f"{base.rstrip('/')}/instances/{instance_id}", api_key)
302
+ item = m._unwrap_items(lookup)
303
+ if isinstance(item, dict) and item.get("name"):
304
+ name = str(item["name"])
305
+ except m.ApiError:
306
+ pass
307
+
308
+ ok, err = _require_interactive_yes(
309
+ f"Delete instance {instance_id} ({name})? Type yes to confirm: "
310
+ )
311
+ if not ok:
312
+ return (make_error(err or "instance delete aborted", stage="usage"), m.EXIT_USAGE)
313
+
314
+ try:
315
+ envelope = m._delete(f"{base.rstrip('/')}/instances/{instance_id}", api_key)
316
+ except m.ApiError as e:
317
+ if e.status in (401, 403):
318
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
319
+ if e.status == 404:
320
+ return (
321
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
322
+ m.EXIT_HTTP_ERROR,
323
+ )
324
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
325
+
326
+ deleted_id = instance_id
327
+ if isinstance(envelope, dict):
328
+ items = envelope.get("items")
329
+ if isinstance(items, list) and items and isinstance(items[0], dict) and items[0].get("id"):
330
+ deleted_id = str(items[0]["id"])
331
+ elif envelope.get("id"):
332
+ deleted_id = str(envelope["id"])
333
+
334
+ return (
335
+ CliOutput(
336
+ OutputKind.INSTANCE_DELETE,
337
+ {"instance_id": deleted_id, "name": name, "status": "deleted"},
338
+ ),
339
+ m.EXIT_OK,
340
+ )
341
+
342
+
343
+ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
344
+ m = xmem_module()
345
+ api_key, _ = m._require(args, need_instance=False)
346
+ base = m._api_url(args)
347
+ cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
348
+ if cluster_err is not None:
349
+ return cluster_err
350
+ if not cluster_id:
351
+ return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
352
+
353
+ if args.schema_stdin and args.schema_file:
354
+ return (
355
+ make_error("pass either --schema-file or --schema-stdin, not both", stage="usage"),
356
+ m.EXIT_USAGE,
357
+ )
358
+ if args.schema_stdin:
359
+ schema_text = sys.stdin.read()
360
+ schema_type = _infer_schema_type(None, args.schema_type)
361
+ elif args.schema_file:
362
+ try:
363
+ schema_text = Path(args.schema_file).read_text(encoding="utf-8")
364
+ except OSError as exc:
365
+ return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
366
+ schema_type = _infer_schema_type(args.schema_file, args.schema_type)
367
+ else:
368
+ return (make_error("--schema-file or --schema-stdin is required", stage="usage"), m.EXIT_USAGE)
369
+
370
+ payload: dict[str, Any] = {
371
+ "name": args.name,
372
+ "instance_schema": _build_instance_schema_payload(schema_text, schema_type),
373
+ }
374
+ if args.description:
375
+ payload["description"] = args.description
376
+
377
+ try:
378
+ envelope = m._post(
379
+ f"{base}/clusters/{cluster_id}/instances",
380
+ payload,
381
+ api_key,
382
+ timeout_s=180.0,
383
+ )
384
+ except m.ApiError as e:
385
+ if e.status == 403:
386
+ limit = _extract_limit_message(e.body)
387
+ if limit is not None:
388
+ msg, console_url = limit
389
+ return (
390
+ make_error(f"{msg} (see {console_url})", status=e.status, stage="limit"),
391
+ m.EXIT_HTTP_ERROR,
392
+ )
393
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
394
+ if e.status == 401:
395
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
396
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
397
+
398
+ item = m._unwrap_items(envelope)
399
+ curated: dict[str, Any] = {
400
+ "instance_id": item.get("id"),
401
+ "name": item.get("name"),
402
+ "console_url": item.get("console_url"),
403
+ }
404
+ if args.verbose:
405
+ for key in ("trace_id", "data_schema", "cluster_id"):
406
+ if key in item and item[key] is not None:
407
+ curated[key] = item[key]
408
+ return (CliOutput(OutputKind.INSTANCE_CREATE, curated), m.EXIT_OK)
409
+
410
+
411
+ def cmd_xmd_generate(args: argparse.Namespace) -> tuple[CliOutput, int]:
412
+ m = xmem_module()
413
+ api_key, _ = m._require(args, need_instance=False)
414
+ base = m._api_url(args)
415
+ cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
416
+ if cluster_err is not None:
417
+ return cluster_err
418
+ if not cluster_id:
419
+ return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
420
+ payload = {"schema_description": args.description}
421
+ try:
422
+ envelope = m._post(
423
+ f"{base}/clusters/{cluster_id}/instances/generate_schema",
424
+ payload,
425
+ api_key,
426
+ timeout_s=120.0,
427
+ )
428
+ except m.ApiError as e:
429
+ if e.status in (401, 403):
430
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
431
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
432
+ item = m._unwrap_items(envelope)
433
+ schema = item.get("data_schema") if isinstance(item, dict) else None
434
+ if args.output and schema is not None:
435
+ Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
436
+ return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
437
+
438
+
439
+ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
440
+ m = xmem_module()
441
+ api_key, _ = m._require(args, need_instance=False)
442
+ base = m._api_url(args)
443
+ cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
444
+ if cluster_err is not None:
445
+ return cluster_err
446
+ if not cluster_id:
447
+ return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
448
+
449
+ from_instance = bool(getattr(args, "from_instance", False))
450
+ schema_file = getattr(args, "schema_file", None)
451
+ if from_instance and schema_file:
452
+ return (
453
+ make_error("pass either --from-instance or a schema_file, not both", stage="usage"),
454
+ m.EXIT_USAGE,
455
+ )
456
+ if from_instance:
457
+ instance_id, id_err = _require_instance_id(args)
458
+ if id_err is not None:
459
+ return id_err
460
+ if instance_id is None:
461
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
462
+ try:
463
+ envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
464
+ except m.ApiError as e:
465
+ if e.status == 404:
466
+ return (
467
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
468
+ m.EXIT_HTTP_ERROR,
469
+ )
470
+ if e.status in (401, 403):
471
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
472
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
473
+ item = m._unwrap_items(envelope)
474
+ schema = item.get("data_schema") if isinstance(item, dict) else None
475
+ if schema is None:
476
+ return (make_error("instance has no live schema to enhance", stage="usage"), m.EXIT_USAGE)
477
+ current_yaml = _yaml_dump_or_json(schema)
478
+ elif schema_file:
479
+ try:
480
+ current_yaml = Path(schema_file).read_text(encoding="utf-8")
481
+ except OSError as exc:
482
+ return (make_error(f"cannot read {schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
483
+ else:
484
+ return (
485
+ make_error(
486
+ "schema_file or --from-instance is required "
487
+ "(MCP enhance_schema uses the bound instance; pass --from-instance with an instance id)",
488
+ stage="usage",
489
+ ),
490
+ m.EXIT_USAGE,
491
+ )
492
+
493
+ payload = {"schema_description": args.description, "current_yml_schema": current_yaml}
494
+ try:
495
+ envelope = m._post(
496
+ f"{base}/clusters/{cluster_id}/instances/generate_schema",
497
+ payload,
498
+ api_key,
499
+ timeout_s=120.0,
500
+ )
501
+ except m.ApiError as e:
502
+ if e.status in (401, 403):
503
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
504
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
505
+ item = m._unwrap_items(envelope)
506
+ migration_plan = item.get("migration_plan") if isinstance(item, dict) else None
507
+ if args.output and isinstance(item, dict) and item.get("data_schema") is not None:
508
+ Path(args.output).write_text(_yaml_dump_or_json(item["data_schema"]), encoding="utf-8")
509
+ if args.plan_output and isinstance(item, dict):
510
+ Path(args.plan_output).write_text(json.dumps(item.get("migration_plan") or {}, indent=2), encoding="utf-8")
511
+ return (CliOutput(OutputKind.XMD_ENHANCE, item), m.EXIT_OK)
512
+
513
+
514
+ def _xmd_validate_impl(
515
+ args: argparse.Namespace,
516
+ *,
517
+ format_hint: str | None,
518
+ ) -> tuple[CliOutput, int]:
519
+ m = xmem_module()
520
+ api_key, _ = m._require(args, need_instance=False)
521
+ base = m._api_url(args)
522
+ cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
523
+ if cluster_err is not None:
524
+ return cluster_err
525
+ if not cluster_id:
526
+ return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
527
+ try:
528
+ text = Path(args.schema_file).read_text(encoding="utf-8")
529
+ except OSError as exc:
530
+ return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
531
+
532
+ use_json = format_hint == "json" or (
533
+ format_hint is None and Path(args.schema_file).suffix.lower() == ".json"
534
+ )
535
+ if use_json:
536
+ try:
537
+ js = json.loads(text)
538
+ except json.JSONDecodeError as exc:
539
+ return (make_error(f"invalid JSON in {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
540
+ url_path = f"/clusters/{cluster_id}/instances/validate_json_schema"
541
+ body: dict[str, Any] = {"json_schema": js}
542
+ else:
543
+ url_path = f"/clusters/{cluster_id}/instances/validate_yaml_schema"
544
+ body = {"yml_schema": text}
545
+ try:
546
+ envelope = m._post(f"{base}{url_path}", body, api_key, timeout_s=30.0)
547
+ except m.ApiError as e:
548
+ if e.status in (401, 403):
549
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
550
+ return (make_error(str(e), status=e.status, stage="validate"), m.EXIT_HTTP_ERROR)
551
+ item = m._unwrap_items(envelope)
552
+ return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
553
+
554
+
555
+ def cmd_xmd_validate(args: argparse.Namespace) -> tuple[CliOutput, int]:
556
+ return _xmd_validate_impl(args, format_hint=None)
557
+
558
+
559
+ def cmd_xmd_validate_yaml(args: argparse.Namespace) -> tuple[CliOutput, int]:
560
+ return _xmd_validate_impl(args, format_hint="yaml")
561
+
562
+
563
+ def cmd_xmd_validate_json(args: argparse.Namespace) -> tuple[CliOutput, int]:
564
+ return _xmd_validate_impl(args, format_hint="json")
565
+
566
+
567
+ def cmd_xmd_convert(args: argparse.Namespace) -> tuple[CliOutput, int]:
568
+ """Convert JSON Schema to XMD YAML via ``POST …/validate_json_schema``."""
569
+ m = xmem_module()
570
+ api_key, _ = m._require(args, need_instance=False)
571
+ base = m._api_url(args)
572
+ cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
573
+ if cluster_err is not None:
574
+ return cluster_err
575
+ if not cluster_id:
576
+ return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
577
+
578
+ input_file = Path(args.schema_file)
579
+ if not input_file.is_file():
580
+ return (make_error(f"cannot read {args.schema_file}: file not found", stage="usage"), m.EXIT_USAGE)
581
+ try:
582
+ json_schema = json.loads(input_file.read_text(encoding="utf-8"))
583
+ except json.JSONDecodeError as exc:
584
+ return (make_error(f"invalid JSON in {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
585
+ except OSError as exc:
586
+ return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
587
+
588
+ root_object = getattr(args, "root_object", None) or "MainObject"
589
+ body: dict[str, Any] = {"json_schema": json_schema, "root_object_name": root_object}
590
+ try:
591
+ envelope = m._post(
592
+ f"{base}/clusters/{cluster_id}/instances/validate_json_schema",
593
+ body,
594
+ api_key,
595
+ timeout_s=30.0,
596
+ )
597
+ except m.ApiError as e:
598
+ if e.status in (401, 403):
599
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
600
+ return (make_error(str(e), status=e.status, stage="convert"), m.EXIT_HTTP_ERROR)
601
+
602
+ item = m._unwrap_items(envelope)
603
+ data_schema = item.get("data_schema") if isinstance(item, dict) else None
604
+ if not isinstance(data_schema, dict):
605
+ return (make_error("conversion returned no data_schema", stage="convert"), m.EXIT_HTTP_ERROR)
606
+
607
+ output_file = Path(args.output) if args.output else input_file.with_suffix(".yml")
608
+ try:
609
+ output_file.parent.mkdir(parents=True, exist_ok=True)
610
+ output_file.write_text(_yaml_dump_or_json(data_schema), encoding="utf-8")
611
+ except OSError as exc:
612
+ return (make_error(f"cannot write {output_file}: {exc}", stage="convert"), m.EXIT_HTTP_ERROR)
613
+
614
+ message = f"Successfully converted '{args.schema_file}' to '{output_file}'\n"
615
+ return (CliOutput(OutputKind.XMD_CONVERT, {"message": message}), m.EXIT_OK)
616
+
617
+
618
+ def cmd_schema_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
619
+ m = xmem_module()
620
+ api_key, _ = m._require(args, need_instance=False)
621
+ instance_id, id_err = _require_instance_id(args)
622
+ if id_err is not None:
623
+ return id_err
624
+ if instance_id is None:
625
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
626
+ base = m._api_url(args)
627
+ try:
628
+ envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
629
+ except m.ApiError as e:
630
+ if e.status == 404:
631
+ return (
632
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
633
+ m.EXIT_HTTP_ERROR,
634
+ )
635
+ if e.status in (401, 403):
636
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
637
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
638
+ item = m._unwrap_items(envelope)
639
+ schema = item.get("data_schema") if isinstance(item, dict) else None
640
+ if args.output and schema is not None:
641
+ Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
642
+ return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
643
+
644
+
645
+ def cmd_schema_update(args: argparse.Namespace) -> tuple[CliOutput, int]:
646
+ m = xmem_module()
647
+ api_key, _ = m._require(args, need_instance=False)
648
+ instance_id, id_err = _require_instance_id(args)
649
+ if id_err is not None:
650
+ return id_err
651
+ if instance_id is None:
652
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
653
+ payload, payload_err = _read_schema_mutation_payload(args)
654
+ if payload_err is not None:
655
+ return payload_err
656
+ if payload is None:
657
+ return (make_error("--schema-file is required", stage="usage"), m.EXIT_USAGE)
658
+ base = m._api_url(args)
659
+ try:
660
+ envelope = m._put(
661
+ f"{base}/instances/{instance_id}/schema",
662
+ payload,
663
+ api_key,
664
+ timeout_s=300.0,
665
+ )
666
+ except m.ApiError as e:
667
+ return _handle_schema_mutation_error(e, instance_id=instance_id)
668
+ item = m._unwrap_items(envelope)
669
+ return (CliOutput(OutputKind.SCHEMA_UPDATE, item), m.EXIT_OK)
670
+
671
+
672
+ def cmd_schema_dry_run(args: argparse.Namespace) -> tuple[CliOutput, int]:
673
+ m = xmem_module()
674
+ api_key, _ = m._require(args, need_instance=False)
675
+ instance_id, id_err = _require_instance_id(args)
676
+ if id_err is not None:
677
+ return id_err
678
+ if instance_id is None:
679
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
680
+ payload, payload_err = _read_schema_mutation_payload(args)
681
+ if payload_err is not None:
682
+ return payload_err
683
+ if payload is None:
684
+ return (make_error("--schema-file is required", stage="usage"), m.EXIT_USAGE)
685
+ base = m._api_url(args)
686
+ try:
687
+ envelope = m._post(
688
+ f"{base}/instances/{instance_id}/migrations/dry_run",
689
+ payload,
690
+ api_key,
691
+ timeout_s=120.0,
692
+ )
693
+ except m.ApiError as e:
694
+ return _handle_schema_mutation_error(e, instance_id=instance_id)
695
+ item = m._unwrap_items(envelope)
696
+ return (CliOutput(OutputKind.SCHEMA_DRY_RUN, item), m.EXIT_OK)
697
+
698
+
699
+ def cmd_schema_migrations_list(args: argparse.Namespace) -> tuple[CliOutput, int]:
700
+ m = xmem_module()
701
+ api_key, _ = m._require(args, need_instance=False)
702
+ instance_id, id_err = _require_instance_id(args)
703
+ if id_err is not None:
704
+ return id_err
705
+ if instance_id is None:
706
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
707
+ base = m._api_url(args)
708
+ params: dict[str, Any] = {
709
+ "limit": getattr(args, "limit", 50),
710
+ "include_yaml": str(bool(getattr(args, "include_yaml", False))).lower(),
711
+ }
712
+ before_id = getattr(args, "before_id", None)
713
+ if before_id:
714
+ params["before_id"] = before_id
715
+ try:
716
+ envelope = _get_with_query(
717
+ f"{base}/instances/{instance_id}/migrations",
718
+ api_key,
719
+ params,
720
+ )
721
+ except m.ApiError as e:
722
+ if e.status == 404:
723
+ return (
724
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
725
+ m.EXIT_HTTP_ERROR,
726
+ )
727
+ if e.status in (401, 403):
728
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
729
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
730
+ item = m._unwrap_items(envelope)
731
+ return (CliOutput(OutputKind.SCHEMA_MIGRATIONS, item), m.EXIT_OK)
732
+
733
+
734
+ def cmd_schema_migrations_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
735
+ m = xmem_module()
736
+ api_key, _ = m._require(args, need_instance=False)
737
+ instance_id, id_err = _require_instance_id(args)
738
+ if id_err is not None:
739
+ return id_err
740
+ if instance_id is None:
741
+ return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
742
+ migration_id = getattr(args, "migration_id_arg", None)
743
+ if not migration_id:
744
+ return (make_error("migration_id is required", stage="usage"), m.EXIT_USAGE)
745
+ base = m._api_url(args)
746
+ params: dict[str, Any] = {
747
+ "include_yaml": str(bool(getattr(args, "include_yaml", False))).lower(),
748
+ }
749
+ try:
750
+ envelope = _get_with_query(
751
+ f"{base}/instances/{instance_id}/migrations/{migration_id}",
752
+ api_key,
753
+ params,
754
+ )
755
+ except m.ApiError as e:
756
+ if e.status == 404:
757
+ return (
758
+ make_error(
759
+ f"migration {migration_id} not found for instance {instance_id}",
760
+ status=e.status,
761
+ stage="request",
762
+ ),
763
+ m.EXIT_HTTP_ERROR,
764
+ )
765
+ if e.status in (401, 403):
766
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
767
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
768
+ item = m._unwrap_items(envelope)
769
+ return (CliOutput(OutputKind.SCHEMA_MIGRATIONS, item), m.EXIT_OK)