ocmo-cli 0.8.19__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.
Files changed (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,658 @@
1
+ """ocmo resolve — resolve a config or folder to its final value.
2
+
3
+ Key behaviours:
4
+ - Lazy download: artifacts only fetched when about to be written/printed.
5
+ - Default output (raw): metadata on stderr, content on stdout per item.
6
+ - --trace-only performs zero artifact downloads.
7
+ - Writes are atomic per file.
8
+ - Resolver hooks are opt-in (OCMO_EXEC_HOOKS or --exec-hooks).
9
+ - --print-hooks exits without resolving (for inspection before enabling hooks).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import shlex
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from collections.abc import Callable, Iterable
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any, cast
24
+
25
+ import click
26
+
27
+ from .._address import parse_address_or_exit
28
+ from .._click_groups import DefaultCommandGroup
29
+ from .._client import OcmoCtx
30
+ from .._errors import sdk_command
31
+ from .._exit import FAILURE, HOOK_FAILURE, USAGE_ERROR, VALIDATION_ERROR
32
+ from .._options import namespace_option
33
+ from .._output import err, warn
34
+ from .._resolve_options import resolve_options
35
+ from .._resolve_output import (
36
+ emit_resolve_item_metadata,
37
+ emit_resolve_results,
38
+ emit_write_outcome,
39
+ emit_write_outcome_jsonpath,
40
+ emit_write_report,
41
+ resolve_output_format,
42
+ )
43
+ from .._resolve_write import (
44
+ ResolveWriteOutcome,
45
+ atomic_write,
46
+ resolve_item_dest,
47
+ save_resolve_item,
48
+ write_resolve_items,
49
+ )
50
+ from .._typing import ResolvedArtifact
51
+
52
+ if TYPE_CHECKING:
53
+ from ocmo.resolve import ResolveResult
54
+
55
+ _EXEC_HOOKS_ENV = "OCMO_EXEC_HOOKS"
56
+ _HOOK_TIMEOUT_DEFAULT = 60
57
+ _CREDENTIAL_VARS = frozenset(
58
+ {
59
+ "OCMO_TOKEN",
60
+ "OCMO_CLIENT_SECRET",
61
+ "OCMO_CLIENT_SECRET_FILE",
62
+ "OCMO_PASSWORD",
63
+ }
64
+ )
65
+
66
+
67
+ _RESOLVE_HELP = """\
68
+ Resolve a config or folder to its final value(s).
69
+
70
+ Default output prints resolve metadata on stderr (``# name: …``) and each
71
+ artifact on stdout. Use ``-o json`` or ``-o yaml`` for the full response with
72
+ ``data`` instead of download URLs.
73
+
74
+ \b
75
+ ADDRESS format: <tree-path>[@<version>]. A trailing slash marks a folder.
76
+
77
+ \b
78
+ Examples:
79
+ ocmo -n prod resolve app/web
80
+ ocmo -n prod resolve app/ --output-dir ./out/
81
+ ocmo -n prod resolve app/ -o name
82
+ ocmo -n prod resolve app/web --cast json --param replicas=5
83
+ ocmo -n prod resolve app/web -O ./app.json
84
+ ocmo -n prod resolve app/ --trace-only -o json
85
+ ocmo -n prod resolve app/web --property database.host
86
+ ocmo -n prod resolve app/web@stable
87
+ """
88
+
89
+
90
+ @click.group("resolve", cls=DefaultCommandGroup, default_command="_default", help=_RESOLVE_HELP)
91
+ @click.pass_context
92
+ def resolve_group(ctx: click.Context) -> None:
93
+ """Resolve configs or inspect resolve metadata."""
94
+ if ctx.invoked_subcommand is None:
95
+ click.echo(ctx.get_help())
96
+
97
+
98
+ @resolve_group.command("_default", hidden=True)
99
+ @click.argument("address")
100
+ @namespace_option()
101
+ @resolve_options()
102
+ @click.pass_obj
103
+ @sdk_command
104
+ def resolve_cmd(
105
+ ctx: OcmoCtx,
106
+ address: str,
107
+ namespace: str | None,
108
+ output_fmt: str | None,
109
+ cast: str | None,
110
+ params: tuple[str, ...],
111
+ cast_options: tuple[str, ...],
112
+ output_file: str | None,
113
+ output_dir: str | None,
114
+ rewrite: bool,
115
+ skip_existing: bool,
116
+ trace_only: bool,
117
+ prop_path: str | None,
118
+ version_flag: str | None,
119
+ exec_hooks: bool,
120
+ hook_timeout: int,
121
+ trust_hooks_sha: str | None,
122
+ print_hooks: bool,
123
+ ) -> None:
124
+ """Resolve a config or folder to its final value.
125
+
126
+ \b
127
+ Examples:
128
+ ocmo -n prod resolve app/web
129
+ ocmo -n prod resolve app/web --cast json --param replicas=5
130
+ ocmo -n prod resolve app/web -O ./app.json
131
+ ocmo -n prod resolve app/ --output-dir ./out/
132
+ ocmo -n prod resolve app/ --trace-only -o json
133
+ ocmo -n prod resolve app/web --property database.host
134
+ """
135
+ path, version = parse_address_or_exit(address, version_flag=version_flag)
136
+
137
+ ns = ctx.require_namespace(namespace)
138
+ view = ctx.namespace_view(namespace)
139
+
140
+ # Parse --param and --cast-option
141
+ params_dict = _parse_kv_args(params)
142
+ cast_options_dict = _parse_kv_args(cast_options)
143
+
144
+ # Build resolve kwargs
145
+ resolve_kwargs: dict[str, Any] = {}
146
+ if cast:
147
+ resolve_kwargs["cast"] = cast
148
+ if version:
149
+ resolve_kwargs["version"] = version
150
+ if trace_only:
151
+ resolve_kwargs["trace_only"] = True
152
+ if params_dict:
153
+ resolve_kwargs["params"] = params_dict
154
+ if cast_options_dict:
155
+ resolve_kwargs["cast_options"] = cast_options_dict
156
+
157
+ if ctx.dry_run:
158
+ from .._dry_run import emit_dry_run_plan, format_resolve_dry_run # deferred
159
+
160
+ emit_dry_run_plan(
161
+ format_resolve_dry_run(
162
+ path=path,
163
+ namespace=ns,
164
+ cast=cast,
165
+ parameters=params_dict or None,
166
+ )
167
+ )
168
+ return
169
+
170
+ if print_hooks:
171
+ resolve_kwargs["trace_only"] = True
172
+
173
+ result = view.resolve(path, **resolve_kwargs)
174
+
175
+ # --print-hooks: show hooks and exit
176
+ if print_hooks:
177
+ _print_hooks(result)
178
+ return
179
+
180
+ run_resolve_pipeline(
181
+ ctx,
182
+ result,
183
+ path=path,
184
+ ns=ns,
185
+ output_fmt=output_fmt,
186
+ output_file=output_file,
187
+ output_dir=output_dir,
188
+ rewrite=rewrite,
189
+ skip_existing=skip_existing,
190
+ trace_only=trace_only,
191
+ prop_path=prop_path,
192
+ exec_hooks=exec_hooks,
193
+ hook_timeout=hook_timeout,
194
+ trust_hooks_sha=trust_hooks_sha,
195
+ )
196
+
197
+
198
+ def run_resolve_pipeline(
199
+ ctx: OcmoCtx,
200
+ result: ResolveResult,
201
+ *,
202
+ path: str,
203
+ ns: str,
204
+ output_fmt: str | None,
205
+ output_file: str | None,
206
+ output_dir: str | None,
207
+ rewrite: bool,
208
+ skip_existing: bool,
209
+ trace_only: bool,
210
+ prop_path: str | None,
211
+ exec_hooks: bool,
212
+ hook_timeout: int,
213
+ trust_hooks_sha: str | None,
214
+ ) -> None:
215
+ """Shared post-resolve output: trace-only, hooks, filesystem writes, or stdout."""
216
+ hooks_mode = _resolve_hooks_mode(exec_hooks)
217
+
218
+ items = list(cast(Iterable[ResolvedArtifact], result)) # evaluates the lazy iterator (metadata only)
219
+
220
+ if not items:
221
+ warn("Resolve returned no items.")
222
+ return
223
+
224
+ output_fmt = resolve_output_format(output_fmt, ctx.output)
225
+
226
+ if rewrite and skip_existing:
227
+ err("Cannot use --rewrite and --skip-existing together.")
228
+ raise SystemExit(USAGE_ERROR)
229
+
230
+ # --trace-only: print metadata, no downloads
231
+ if trace_only:
232
+ if output_fmt == "raw":
233
+ output_fmt = "yaml"
234
+ emit_resolve_results(items, output_fmt, no_color=ctx.no_color, include_data=False)
235
+ return
236
+
237
+ # --property: stdout-only when not writing to filesystem
238
+ if prop_path and not output_dir and not output_file:
239
+ if len(items) != 1:
240
+ err(f"--property requires exactly one resolved item; got {len(items)}.")
241
+ raise SystemExit(USAGE_ERROR)
242
+ _print_property(items[0], prop_path)
243
+ return
244
+
245
+ # Execute hooks when requested
246
+ if hooks_mode != "never":
247
+ if output_file and len(items) > 1:
248
+ err("--output-file requires exactly one resolved item.")
249
+ raise SystemExit(USAGE_ERROR)
250
+ _run_with_hooks(
251
+ result,
252
+ items,
253
+ path,
254
+ ns,
255
+ output_dir,
256
+ output_file,
257
+ hooks_mode=hooks_mode,
258
+ hook_timeout=hook_timeout,
259
+ trust_hooks_sha=trust_hooks_sha,
260
+ yes=ctx.yes,
261
+ rewrite=rewrite,
262
+ skip_existing=skip_existing,
263
+ output_fmt=output_fmt,
264
+ no_color=ctx.no_color,
265
+ prop_path=prop_path,
266
+ )
267
+ return
268
+
269
+ # Write to filesystem
270
+ if output_dir or output_file:
271
+ if output_file and len(items) > 1:
272
+ err("--output-file requires exactly one resolved item.")
273
+ raise SystemExit(USAGE_ERROR)
274
+ _save_and_report(
275
+ items,
276
+ output_file=output_file,
277
+ output_dir=output_dir,
278
+ rewrite=rewrite,
279
+ skip_existing=skip_existing,
280
+ output_fmt=output_fmt,
281
+ no_color=ctx.no_color,
282
+ prop_path=prop_path,
283
+ )
284
+ return
285
+
286
+ emit_resolve_results(items, output_fmt, no_color=ctx.no_color)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Helpers
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def _parse_kv_args(args: tuple[str, ...]) -> dict[str, str]:
295
+ result: dict[str, str] = {}
296
+ for arg in args:
297
+ if "=" not in arg:
298
+ err(f"Invalid key=value argument: {arg!r}")
299
+ raise SystemExit(USAGE_ERROR)
300
+ k, v = arg.split("=", 1)
301
+ result[k] = v
302
+ return result
303
+
304
+
305
+ def _resolve_hooks_mode(exec_hooks_flag: bool) -> str:
306
+ if exec_hooks_flag:
307
+ return "always"
308
+ env = os.environ.get(_EXEC_HOOKS_ENV, "never").lower()
309
+ return env if env in ("never", "prompt", "always") else "never"
310
+
311
+
312
+ def _print_hooks(result: ResolveResult) -> None:
313
+ hooks = _extract_hooks(result)
314
+ if hooks is None:
315
+ err("No hooks configured.")
316
+ return
317
+ for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
318
+ val = getattr(hooks, field, None)
319
+ if val:
320
+ err(f"{field}: {val}")
321
+
322
+
323
+ def _get_property_value(item: ResolvedArtifact, prop_path: str) -> Any:
324
+ from ocmo.errors import PropertyNotFoundError, UnstructuredFormatError
325
+
326
+ try:
327
+ return item.get(prop_path)
328
+ except UnstructuredFormatError as e:
329
+ err(str(e))
330
+ raise SystemExit(VALIDATION_ERROR)
331
+ except PropertyNotFoundError as e:
332
+ err(str(e))
333
+ raise SystemExit(VALIDATION_ERROR)
334
+
335
+
336
+ def _property_value_bytes(value: Any) -> bytes:
337
+ if isinstance(value, dict | list):
338
+ text = json.dumps(value, default=str)
339
+ else:
340
+ text = str(value)
341
+ return f"{text}\n".encode()
342
+
343
+
344
+ def _item_write_bytes(item: ResolvedArtifact, prop_path: str | None) -> tuple[bytes, bool]:
345
+ if not prop_path:
346
+ return item.bytes, False
347
+ return _property_value_bytes(_get_property_value(item, prop_path)), True
348
+
349
+
350
+ def _item_data_fn(prop_path: str | None) -> Callable[[ResolvedArtifact], tuple[bytes, bool]] | None:
351
+ if not prop_path:
352
+ return None
353
+ return lambda item: _item_write_bytes(item, prop_path)
354
+
355
+
356
+ def _print_property(item: ResolvedArtifact, prop_path: str) -> None:
357
+ value = _get_property_value(item, prop_path)
358
+ if isinstance(value, dict | list):
359
+ print(json.dumps(value, default=str))
360
+ else:
361
+ print(value)
362
+
363
+
364
+ def _write_report_format(output_fmt: str) -> str:
365
+ return output_fmt if output_fmt in ("json", "yaml") else "raw"
366
+
367
+
368
+ def _is_interleaved_save_format(output_fmt: str) -> bool:
369
+ return output_fmt in ("raw", "name") or output_fmt.startswith("jsonpath=")
370
+
371
+
372
+ def _save_and_report(
373
+ items: list[ResolvedArtifact],
374
+ *,
375
+ output_file: str | None,
376
+ output_dir: str | None,
377
+ rewrite: bool,
378
+ skip_existing: bool,
379
+ output_fmt: str,
380
+ no_color: bool,
381
+ prop_path: str | None = None,
382
+ ) -> list[ResolveWriteOutcome]:
383
+ output_dir_value = output_dir or (None if output_file else ".")
384
+ item_data = _item_data_fn(prop_path)
385
+
386
+ if _is_interleaved_save_format(output_fmt):
387
+ if output_file and len(items) != 1:
388
+ err("--output-file requires exactly one resolved item.")
389
+ raise SystemExit(USAGE_ERROR)
390
+ if output_dir_value:
391
+ Path(output_dir_value).mkdir(parents=True, exist_ok=True)
392
+
393
+ outcomes: list[ResolveWriteOutcome] = []
394
+ for index, item in enumerate(items):
395
+ if output_fmt == "raw" and index > 0:
396
+ print(file=sys.stderr)
397
+ if output_fmt == "raw":
398
+ emit_resolve_item_metadata(item, no_color=no_color)
399
+ elif output_fmt == "name":
400
+ name = getattr(item, "name", None)
401
+ if name:
402
+ print(name)
403
+ try:
404
+ dest = resolve_item_dest(
405
+ item,
406
+ output_file=output_file,
407
+ output_dir=output_dir_value or ".",
408
+ )
409
+ except ValueError as exc:
410
+ err(str(exc))
411
+ raise SystemExit(USAGE_ERROR) from exc
412
+ data, skip_checksum = _item_write_bytes(item, prop_path)
413
+ outcome = save_resolve_item(
414
+ item,
415
+ dest,
416
+ rewrite=rewrite,
417
+ skip_existing=skip_existing,
418
+ data=data,
419
+ skip_checksum=skip_checksum,
420
+ )
421
+ outcomes.append(outcome)
422
+ if output_fmt == "raw" or output_fmt == "name":
423
+ emit_write_outcome(outcome, "raw", no_color=no_color)
424
+ elif output_fmt.startswith("jsonpath="):
425
+ emit_write_outcome_jsonpath(outcome, output_fmt[9:])
426
+
427
+ if any(outcome.result == "failed" for outcome in outcomes):
428
+ raise SystemExit(FAILURE)
429
+ return outcomes
430
+
431
+ try:
432
+ outcomes = write_resolve_items(
433
+ items,
434
+ output_file=output_file,
435
+ output_dir=output_dir_value,
436
+ rewrite=rewrite,
437
+ skip_existing=skip_existing,
438
+ item_data=item_data,
439
+ )
440
+ except ValueError as exc:
441
+ err(str(exc))
442
+ raise SystemExit(USAGE_ERROR) from exc
443
+
444
+ emit_write_report(outcomes, _write_report_format(output_fmt), no_color=no_color)
445
+
446
+ if any(outcome.result == "failed" for outcome in outcomes):
447
+ raise SystemExit(FAILURE)
448
+
449
+ return outcomes
450
+
451
+
452
+ def _run_with_hooks(
453
+ result: ResolveResult,
454
+ items: list[ResolvedArtifact],
455
+ path: str,
456
+ ns: str,
457
+ output_dir: str | None,
458
+ output_file: str | None,
459
+ *,
460
+ hooks_mode: str,
461
+ hook_timeout: int,
462
+ trust_hooks_sha: str | None,
463
+ yes: bool,
464
+ rewrite: bool,
465
+ skip_existing: bool,
466
+ output_fmt: str,
467
+ no_color: bool,
468
+ prop_path: str | None = None,
469
+ ) -> None:
470
+ """Full pipeline: stage → validate → place → post_resolve, per §13."""
471
+ hooks = _extract_hooks(result)
472
+ if hooks is None:
473
+ if output_dir or output_file:
474
+ _save_and_report(
475
+ items,
476
+ output_file=output_file,
477
+ output_dir=output_dir,
478
+ rewrite=rewrite,
479
+ skip_existing=skip_existing,
480
+ output_fmt=output_fmt,
481
+ no_color=no_color,
482
+ prop_path=prop_path,
483
+ )
484
+ return
485
+
486
+ hook_block_sha = _sha256_hooks(hooks)
487
+
488
+ # Trust pinning
489
+ if hooks_mode == "always" and trust_hooks_sha and trust_hooks_sha != hook_block_sha:
490
+ err(
491
+ f"Hook configuration has changed.\n"
492
+ f" Expected SHA-256: {trust_hooks_sha}\n"
493
+ f" Current SHA-256: {hook_block_sha}\n"
494
+ "Re-run with --trust-hooks to accept the new configuration."
495
+ )
496
+ raise SystemExit(HOOK_FAILURE)
497
+
498
+ # Consent
499
+ if hooks_mode == "prompt":
500
+ if not sys.stdin.isatty():
501
+ err("Hook execution requires TTY for 'prompt' mode. Use --exec-hooks or OCMO_EXEC_HOOKS=always.")
502
+ raise SystemExit(HOOK_FAILURE)
503
+ print("The following hooks will be executed:", file=sys.stderr)
504
+ _print_hooks(result)
505
+ answer = input("Execute? [y/N] ").strip().lower()
506
+ if answer not in ("y", "yes"):
507
+ err("Hook execution declined.")
508
+ raise SystemExit(HOOK_FAILURE)
509
+
510
+ # Stage into temp directory
511
+ with tempfile.TemporaryDirectory() as staging_str:
512
+ staging = Path(staging_str)
513
+ staged: list[Path] = []
514
+ for item in items:
515
+ name = getattr(item, "name", "output")
516
+ dest = staging / name
517
+ dest.parent.mkdir(parents=True, exist_ok=True)
518
+ data, _skip_checksum = _item_write_bytes(item, prop_path)
519
+ atomic_write(dest, data)
520
+ staged.append(dest)
521
+
522
+ env = _scrub_env(ns=ns, path=path, items=staged)
523
+
524
+ # Validation phase
525
+ _run_hook_phase(
526
+ hooks=hooks,
527
+ phase="validate",
528
+ staged=staged,
529
+ env=env,
530
+ timeout=hook_timeout,
531
+ quiet=False,
532
+ )
533
+
534
+ # Place files with checksum-aware idempotency
535
+ outcomes = _save_and_report(
536
+ items,
537
+ output_file=output_file,
538
+ output_dir=output_dir,
539
+ rewrite=rewrite,
540
+ skip_existing=skip_existing,
541
+ output_fmt=output_fmt,
542
+ no_color=no_color,
543
+ prop_path=prop_path,
544
+ )
545
+ placed = [outcome.path for outcome in outcomes if outcome.result != "failed"]
546
+
547
+ # Post-resolve phase
548
+ env_placed = _scrub_env(ns=ns, path=path, items=placed)
549
+ _run_hook_phase(
550
+ hooks=hooks,
551
+ phase="post_resolve",
552
+ staged=placed,
553
+ env=env_placed,
554
+ timeout=hook_timeout,
555
+ quiet=False,
556
+ )
557
+
558
+
559
+ def _extract_hooks(result: ResolveResult) -> Any | None:
560
+ resolver = getattr(result, "resolver", None)
561
+ if resolver is None:
562
+ return None
563
+ hooks = getattr(resolver, "hooks", None)
564
+ if hooks is None:
565
+ return None
566
+ # Check if any hook is set
567
+ for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
568
+ if getattr(hooks, field, None):
569
+ return hooks
570
+ return None
571
+
572
+
573
+ def _sha256_hooks(hooks: Any) -> str:
574
+ parts = []
575
+ for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
576
+ v = getattr(hooks, field, None) or ""
577
+ parts.append(f"{field}={v}")
578
+ return hashlib.sha256("|".join(parts).encode()).hexdigest()
579
+
580
+
581
+ def _scrub_env(ns: str, path: str, items: list[Path]) -> dict[str, str]:
582
+ env = {k: v for k, v in os.environ.items() if k not in _CREDENTIAL_VARS}
583
+ env["OCMO_HOOK_NAMESPACE"] = ns
584
+ env["OCMO_HOOK_PATH"] = path
585
+ env["OCMO_HOOK_ITEM"] = str(items[0]) if items else ""
586
+ env["OCMO_HOOK_FILES"] = " ".join(shlex.quote(str(p)) for p in items)
587
+ return env
588
+
589
+
590
+ def _run_hook_phase(
591
+ *,
592
+ hooks: Any,
593
+ phase: str,
594
+ staged: list[Path],
595
+ env: dict[str, str],
596
+ timeout: int,
597
+ quiet: bool,
598
+ ) -> None:
599
+ """Run the validate or post_resolve hook phase — exactly one of per/all."""
600
+ per_hook = getattr(hooks, phase, None)
601
+ all_hook = getattr(hooks, f"{phase}_all", None)
602
+
603
+ if all_hook:
604
+ all_paths = " ".join(shlex.quote(str(p)) for p in staged)
605
+ cmd = all_hook.replace("{!conf}", all_paths) if "{!conf}" in all_hook else f"{all_hook} {all_paths}"
606
+ if not quiet:
607
+ err(f"[hook:{phase}_all] {cmd}")
608
+ _exec_hook(cmd, env=env, timeout=timeout, cwd=str(staged[0].parent) if staged else ".")
609
+ elif per_hook:
610
+ for item_path in staged:
611
+ quoted = shlex.quote(str(item_path))
612
+ cmd = per_hook.replace("{!conf}", quoted) if "{!conf}" in per_hook else f"{per_hook} {quoted}"
613
+ if not quiet:
614
+ err(f"[hook:{phase}] {cmd}")
615
+ _exec_hook(cmd, env=env, timeout=timeout, cwd=str(item_path.parent))
616
+
617
+
618
+ def _exec_hook(cmd: str, *, env: dict[str, str], timeout: int, cwd: str) -> None:
619
+ try:
620
+ proc = subprocess.Popen(
621
+ cmd,
622
+ shell=True,
623
+ env=env,
624
+ cwd=cwd,
625
+ stdout=subprocess.PIPE,
626
+ stderr=subprocess.STDOUT,
627
+ text=True,
628
+ )
629
+ try:
630
+ out, _ = proc.communicate(timeout=timeout)
631
+ except subprocess.TimeoutExpired:
632
+ proc.kill()
633
+ proc.communicate()
634
+ err(f"Hook timed out after {timeout}s: {cmd!r}")
635
+ raise SystemExit(HOOK_FAILURE)
636
+
637
+ prefix = "[hook] "
638
+ for line in out.splitlines():
639
+ err(f"{prefix}{line}")
640
+
641
+ if proc.returncode != 0:
642
+ err(f"Hook failed (exit {proc.returncode}): {cmd!r}")
643
+ raise SystemExit(HOOK_FAILURE)
644
+
645
+ except SystemExit:
646
+ raise
647
+ except Exception as exc:
648
+ err(f"Hook execution error: {exc}")
649
+ raise SystemExit(HOOK_FAILURE)
650
+
651
+
652
+ from .resolve_parameters import resolve_parameters_cmd # noqa: E402
653
+
654
+ resolve_group.add_command(resolve_parameters_cmd)
655
+
656
+ from .resolve_draft import resolve_draft_cmd # noqa: E402
657
+
658
+ resolve_group.add_command(resolve_draft_cmd)