licos-platform-cli 0.2.14__tar.gz → 0.3.1__tar.gz

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.
@@ -12,6 +12,7 @@ packages/*/dist/
12
12
  .vscode/
13
13
  *.swp
14
14
  *.swo
15
+ *.pyc
15
16
 
16
17
  # OS
17
18
  .DS_Store
@@ -33,12 +34,13 @@ crates/industrial/industrial-stack.env
33
34
  # Build
34
35
  *.log
35
36
  *.pid
36
- crates/industrial/bin/
37
37
  .licos
38
38
  .tmp
39
39
  .playwright-cli
40
40
 
41
41
  /tmp
42
+ crates/industrial/bin/
43
+
42
44
  tools/android-sdk-cache/*.zip
43
45
  tools/codegraph-cache/*.tgz
44
46
  tools/codegraph-cache/*.tgz
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.2.14
3
+ Version: 0.3.1
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: licos-platform-sdk>=0.3.0
6
+ Requires-Dist: licos-platform-sdk>=0.4.1
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-platform-cli"
7
- version = "0.2.14"
7
+ version = "0.3.1"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.3.0",
11
+ "licos-platform-sdk>=0.4.1",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -451,7 +451,7 @@ KNOWLEDGE_HELP = {
451
451
  }
452
452
 
453
453
 
454
- OAUTH_HELP = {
454
+ OAUTH_HELP = {
455
455
  "topic": "oauth",
456
456
  "description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
457
457
  "runtime": RUNTIME_ENV,
@@ -495,14 +495,201 @@ OAUTH_HELP = {
495
495
  "grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
496
496
  "revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
497
497
  },
498
- }
499
-
500
-
501
- HELP_TOPICS = {
498
+ }
499
+
500
+
501
+ UNS_HELP = {
502
+ "topic": "uns",
503
+ "description": "Project-scoped UNS generation, reconciliation, resource search, and business runtime access.",
504
+ "runtime": RUNTIME_ENV,
505
+ "rules": [
506
+ "Runtime identity selects the project; never pass tenant, workspace, project, external service URL, or token arguments.",
507
+ "Query only the blocks and protocols needed for the current document.",
508
+ "discover, validate, plan, and finalize are read-only for external systems; apply performs writes.",
509
+ "Run apply only after reviewing the current plan, and pass its exact documentHash and planHash with --confirm.",
510
+ "UNS.json must not contain platform IDs, external IDs, credentials, MQTT infrastructure IDs, or credential-bearing RTSP URLs.",
511
+ "Use resource-search before reference bindings; a reference selector must resolve exactly one existing resource.",
512
+ "Use either a current-project node code or --path for business access. Data writes/deletions, point control, and video control require explicit confirmation.",
513
+ ],
514
+ "sdk": {
515
+ "python": "from licos_platform_sdk import uns\nplanned = uns.plan(document)\nresult = uns.apply(document, expected_document_hash=planned['documentHash'], expected_plan_hash=planned['planHash'], confirmed=True)",
516
+ },
517
+ "commands": {
518
+ "schema-current": {
519
+ "description": "Get the current complete UNS Schema and block index.",
520
+ "parameters": [],
521
+ "example": "licos-platform uns schema-current",
522
+ },
523
+ "schema": {
524
+ "description": "Get a complete versioned UNS Schema.",
525
+ "parameters": [{"name": "--version", "required": False, "description": "Schema version, default 1.0."}],
526
+ "example": "licos-platform uns schema --version 1.0",
527
+ },
528
+ "schema-blocks": {
529
+ "description": "List available blocks for one Schema version.",
530
+ "parameters": [{"name": "--version", "required": False, "description": "Schema version, default 1.0."}],
531
+ "example": "licos-platform uns schema-blocks",
532
+ },
533
+ "schema-block": {
534
+ "description": "Get one block and optionally its direct dependencies.",
535
+ "parameters": [
536
+ {"name": "block_code", "required": True, "description": "Block code such as collection-point or uns-node."},
537
+ {"name": "--depth", "required": False, "description": "Dependency depth from 0 to 3."},
538
+ ],
539
+ "example": "licos-platform uns schema-block collection-point --depth 1",
540
+ },
541
+ "protocol-schema": {
542
+ "description": "Get one protocol-specific collection-point Schema and capability status.",
543
+ "parameters": [{"name": "protocol", "required": True, "description": "Protocol code such as opcua."}],
544
+ "example": "licos-platform uns protocol-schema opcua",
545
+ },
546
+ "discover": {
547
+ "description": "Read a redacted snapshot of reusable industrial resources for the runtime project.",
548
+ "parameters": [
549
+ {"name": "--resource-type", "required": False, "description": "Resource block to return; can repeat."},
550
+ {"name": "--protocol", "required": False, "description": "Protocol capability detail to query; can repeat."},
551
+ ],
552
+ "example": "licos-platform uns discover --resource-type connections --protocol opcua",
553
+ },
554
+ "resource-search": {
555
+ "description": "Search reusable points, video channels, or other resources and return a manifestSelector without external IDs.",
556
+ "parameters": [
557
+ {"name": "--resource-type", "required": True, "description": "Resource type such as collectionPoints or videoChannels."},
558
+ {"name": "--protocol", "required": False, "description": "Optional protocol filter."},
559
+ {"name": "--keyword", "required": False, "description": "Name or business-code search text."},
560
+ {"name": "--selector-json", "required": False, "description": "Exact business selector JSON object."},
561
+ ],
562
+ "example": "licos-platform uns resource-search --resource-type collectionPoints --protocol opcua --keyword TEMP",
563
+ },
564
+ "bindings": {
565
+ "description": "List project UNS node codes, field contracts, and available runtime capabilities.",
566
+ "parameters": [],
567
+ "example": "licos-platform uns bindings",
568
+ },
569
+ "path-resolve": {
570
+ "description": "Resolve an authorized absolute or relative enterprise UNS path.",
571
+ "parameters": [{"name": "path", "required": True, "description": "UNS absolute or relative path."}],
572
+ "example": "licos-platform uns path-resolve project:/AREA_01/TEMP_01",
573
+ },
574
+ "validate": {
575
+ "description": "Validate a local JSON document in the runtime project scope.",
576
+ "parameters": [{"name": "--file", "required": True, "description": "Local UNS JSON file."}],
577
+ "example": "licos-platform uns validate --file UNS.json.candidate",
578
+ },
579
+ "plan": {
580
+ "description": "Create a deterministic read-only reconciliation plan for a local JSON document.",
581
+ "parameters": [{"name": "--file", "required": True, "description": "Local UNS JSON file."}],
582
+ "example": "licos-platform uns plan --file UNS.json",
583
+ },
584
+ "apply": {
585
+ "description": "Execute a previously reviewed plan after explicit confirmation and hash verification.",
586
+ "parameters": [
587
+ {"name": "--file", "required": True, "description": "The exact local UNS JSON document that was planned."},
588
+ {"name": "--document-hash", "required": True, "description": "documentHash returned by the reviewed plan."},
589
+ {"name": "--plan-hash", "required": True, "description": "planHash returned by the reviewed plan."},
590
+ {"name": "--confirm", "required": True, "description": "Explicitly confirm external writes."},
591
+ ],
592
+ "example": "licos-platform uns apply --file UNS.json --document-hash <documentHash> --plan-hash <planHash> --confirm",
593
+ },
594
+ "finalize": {
595
+ "description": "Validate and atomically install a candidate as project-root UNS.json, revalidate it, and return a read-only plan.",
596
+ "parameters": [{"name": "--file", "required": True, "description": "Candidate JSON file inside LICOS_PROJECT_PATH."}],
597
+ "example": "licos-platform uns finalize --file .UNS.json.candidate",
598
+ },
599
+ "data-query": {
600
+ "description": "Query stored rows through a data or point UNS node.",
601
+ "parameters": [
602
+ {"name": "node_code", "required": False, "description": "Current-project UNS node code; mutually exclusive with --path."},
603
+ {"name": "--path", "required": False, "description": "Authorized UNS path; mutually exclusive with node_code."},
604
+ ],
605
+ "example": "licos-platform uns data-query ORDERS --page-size 50",
606
+ },
607
+ "data-write": {
608
+ "description": "Write structured fields through a UNS node after explicit confirmation.",
609
+ "parameters": [
610
+ {"name": "node_code", "required": False, "description": "Current-project UNS node code; mutually exclusive with --path."},
611
+ {"name": "--path", "required": False, "description": "Authorized UNS path; mutually exclusive with node_code."},
612
+ {"name": "--fields-json", "required": True, "description": "Field-value JSON object."},
613
+ {"name": "--confirm", "required": True, "description": "Explicitly confirm the write."},
614
+ ],
615
+ "example": "licos-platform uns data-write ORDERS --fields-json '{\"amount\":12}' --confirm",
616
+ },
617
+ "data-delete": {
618
+ "description": "Delete exact rows returned by a prior UNS query after explicit confirmation.",
619
+ "parameters": [
620
+ {"name": "node_code", "required": False, "description": "Current-project UNS node code; mutually exclusive with --path."},
621
+ {"name": "--path", "required": False, "description": "Authorized UNS path; mutually exclusive with node_code."},
622
+ {"name": "--rows-json", "required": True, "description": "Non-empty JSON array containing exact queried row objects."},
623
+ {"name": "--confirm", "required": True, "description": "Explicitly confirm the deletion."},
624
+ ],
625
+ "example": "licos-platform uns data-delete ORDERS --rows-json '[{\"orderNo\":\"SO-1\"}]' --confirm",
626
+ },
627
+ "point-current": {
628
+ "description": "Read the current value of a bound UNS point.",
629
+ "parameters": [
630
+ {"name": "node_code", "required": False, "description": "Current-project point code; mutually exclusive with --path."},
631
+ {"name": "--path", "required": False, "description": "Authorized point path; mutually exclusive with node_code."},
632
+ ],
633
+ "example": "licos-platform uns point-current TEMP_01",
634
+ },
635
+ "point-history": {
636
+ "description": "Read an ISO-8601 time range of historical point values.",
637
+ "parameters": [
638
+ {"name": "node_code", "required": False, "description": "Current-project point code; mutually exclusive with --path."},
639
+ {"name": "--path", "required": False, "description": "Authorized point path; mutually exclusive with node_code."},
640
+ {"name": "--start-time / --end-time", "required": True, "description": "UTC ISO-8601 range."},
641
+ ],
642
+ "example": "licos-platform uns point-history TEMP_01 --start-time 2026-07-13T00:00:00Z --end-time 2026-07-14T00:00:00Z",
643
+ },
644
+ "point-write": {
645
+ "description": "Dry-run or explicitly confirm a writable point control with audit reason and ticket.",
646
+ "parameters": [
647
+ {"name": "node_code", "required": False, "description": "Current-project point code; mutually exclusive with --path."},
648
+ {"name": "--path", "required": False, "description": "Authorized point path; mutually exclusive with node_code."},
649
+ {"name": "--value-json", "required": True, "description": "Typed JSON scalar or value."},
650
+ {"name": "--reason / --ticket", "required": True, "description": "Audit context."},
651
+ {"name": "--request-id", "required": False, "description": "Idempotency/correlation key; generated by the platform when omitted."},
652
+ {"name": "--dry-run / --confirm", "required": True, "description": "Dry-run or explicit execution confirmation."},
653
+ ],
654
+ "example": "licos-platform uns point-write MOTOR_START --value-json true --reason 'start line' --ticket WO-100 --dry-run",
655
+ },
656
+ "video-stream": {
657
+ "description": "Get credential-safe playback options for a UNS video node.",
658
+ "parameters": [
659
+ {"name": "node_code", "required": False, "description": "Current-project video code; mutually exclusive with --path."},
660
+ {"name": "--path", "required": False, "description": "Authorized video path; mutually exclusive with node_code."},
661
+ ],
662
+ "example": "licos-platform uns video-stream CAMERA_01",
663
+ },
664
+ "video-snapshot": {
665
+ "description": "Capture a snapshot from a UNS video node.",
666
+ "parameters": [
667
+ {"name": "node_code", "required": False, "description": "Current-project video code; mutually exclusive with --path."},
668
+ {"name": "--path", "required": False, "description": "Authorized video path; mutually exclusive with node_code."},
669
+ ],
670
+ "example": "licos-platform uns video-snapshot CAMERA_01",
671
+ },
672
+ "video-control": {
673
+ "description": "Execute an explicitly confirmed PTZ or preset action.",
674
+ "parameters": [
675
+ {"name": "node_code", "required": False, "description": "Current-project video code; mutually exclusive with --path."},
676
+ {"name": "--path", "required": False, "description": "Authorized video path; mutually exclusive with node_code."},
677
+ {"name": "--action", "required": True, "description": "move, stop, preset-set, or preset-goto."},
678
+ {"name": "--parameters-json", "required": False, "description": "Action-specific JSON parameters."},
679
+ {"name": "--confirm", "required": True, "description": "Explicitly confirm video control."},
680
+ ],
681
+ "example": "licos-platform uns video-control CAMERA_01 --action move --parameters-json '{\"direction\":\"left\"}' --confirm",
682
+ },
683
+ },
684
+ }
685
+
686
+
687
+ HELP_TOPICS = {
502
688
  "storage": STORAGE_HELP,
503
689
  "database": DATABASE_HELP,
504
690
  "knowledge": KNOWLEDGE_HELP,
505
- "oauth": OAUTH_HELP,
691
+ "oauth": OAUTH_HELP,
692
+ "uns": UNS_HELP,
506
693
  }
507
694
 
508
695
 
@@ -514,20 +701,22 @@ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
514
701
  if not args.topic:
515
702
  return {
516
703
  "description": "Structured help for LICOS platform CLI topics.",
517
- "usage": [
518
- "licos-platform help storage",
519
- "licos-platform help storage upload-file",
520
- "licos-platform help database",
521
- "licos-platform help database query",
522
- "licos-platform help database export-orm",
523
- "licos-platform help database studio-ensure",
524
- "licos-platform help knowledge",
525
- "licos-platform help knowledge search",
526
- "licos-platform help oauth",
527
- "licos-platform help oauth configure-project",
528
- ],
529
- "topics": sorted(HELP_TOPICS),
530
- }
704
+ "usage": [
705
+ "licos-platform help storage",
706
+ "licos-platform help storage upload-file",
707
+ "licos-platform help database",
708
+ "licos-platform help database query",
709
+ "licos-platform help database export-orm",
710
+ "licos-platform help database studio-ensure",
711
+ "licos-platform help knowledge",
712
+ "licos-platform help knowledge search",
713
+ "licos-platform help oauth",
714
+ "licos-platform help oauth configure-project",
715
+ "licos-platform help uns",
716
+ "licos-platform help uns protocol-schema",
717
+ ],
718
+ "topics": sorted(HELP_TOPICS),
719
+ }
531
720
  topic = HELP_TOPICS[args.topic]
532
721
  if not args.command:
533
722
  return topic
@@ -11,7 +11,8 @@ from .database_commands import add_database_parser
11
11
  from .help_commands import add_help_parser
12
12
  from .knowledge_commands import add_knowledge_parser
13
13
  from .oauth_commands import add_oauth_parser
14
- from .storage_commands import add_storage_parser
14
+ from .storage_commands import add_storage_parser
15
+ from .uns_commands import add_uns_parser
15
16
 
16
17
 
17
18
  def _print_json(value: Any) -> None:
@@ -33,7 +34,8 @@ def _build_parser() -> argparse.ArgumentParser:
33
34
  add_storage_parser(subparsers)
34
35
  add_database_parser(subparsers)
35
36
  add_knowledge_parser(subparsers)
36
- add_oauth_parser(subparsers)
37
+ add_oauth_parser(subparsers)
38
+ add_uns_parser(subparsers)
37
39
  return parser
38
40
 
39
41
 
@@ -137,12 +137,12 @@ def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
137
137
  return oauth.revoke_grant(args.grant_id)
138
138
 
139
139
 
140
- def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
141
- public_base_url = (
142
- args.public_base_url
143
- or os.environ.get("LICOS_PLATFORM_PUBLIC_BASE_URL")
144
- or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
145
- )
140
+ def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
141
+ public_base_url = (
142
+ args.public_base_url
143
+ or os.environ.get("LICOS_PLATFORM_PUBLIC_BASE_URL")
144
+ or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
145
+ )
146
146
  if not public_base_url:
147
147
  raise argparse.ArgumentTypeError(
148
148
  "--public-base-url, LICOS_PLATFORM_PUBLIC_BASE_URL, or LICOS_OAUTH_PUBLIC_BASE_URL is required"
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from licos_platform_sdk import PlatformSdkError, uns
11
+
12
+
13
+ Handler = Callable[[argparse.Namespace], Any]
14
+
15
+
16
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
17
+ parser.set_defaults(handler=handler)
18
+
19
+
20
+ def _document(path: str) -> dict[str, Any]:
21
+ file_path = Path(path)
22
+ try:
23
+ payload = json.loads(file_path.read_text(encoding="utf-8"))
24
+ except OSError as exc:
25
+ raise argparse.ArgumentTypeError(f"cannot read UNS file: {exc}") from exc
26
+ except json.JSONDecodeError as exc:
27
+ raise argparse.ArgumentTypeError(
28
+ f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
29
+ ) from exc
30
+ if not isinstance(payload, dict):
31
+ raise argparse.ArgumentTypeError("UNS document root must be an object")
32
+ return payload
33
+
34
+
35
+ def _json_object(value: str | None, label: str) -> dict[str, Any]:
36
+ if value is None:
37
+ return {}
38
+ try:
39
+ payload = json.loads(value)
40
+ except json.JSONDecodeError as exc:
41
+ raise argparse.ArgumentTypeError(f"{label} must be valid JSON: {exc.msg}") from exc
42
+ if not isinstance(payload, dict):
43
+ raise argparse.ArgumentTypeError(f"{label} must be a JSON object")
44
+ return payload
45
+
46
+
47
+ def _json_value(value: str, label: str) -> Any:
48
+ try:
49
+ return json.loads(value)
50
+ except json.JSONDecodeError as exc:
51
+ raise argparse.ArgumentTypeError(f"{label} must be valid JSON: {exc.msg}") from exc
52
+
53
+
54
+ def _json_object_array(value: str, label: str) -> list[dict[str, Any]]:
55
+ payload = _json_value(value, label)
56
+ if not isinstance(payload, list) or not payload:
57
+ raise argparse.ArgumentTypeError(f"{label} must be a non-empty JSON array")
58
+ if any(not isinstance(item, dict) or not item for item in payload):
59
+ raise argparse.ArgumentTypeError(f"{label} must contain only non-empty JSON objects")
60
+ return payload
61
+
62
+
63
+ def _cmd_schema_current(_args: argparse.Namespace) -> Any:
64
+ return uns.schema_current()
65
+
66
+
67
+ def _cmd_schema(args: argparse.Namespace) -> Any:
68
+ return uns.schema(args.version)
69
+
70
+
71
+ def _cmd_schema_blocks(args: argparse.Namespace) -> Any:
72
+ return uns.schema_blocks(args.version)
73
+
74
+
75
+ def _cmd_schema_block(args: argparse.Namespace) -> Any:
76
+ return uns.schema_block(args.block_code, version=args.version, depth=args.depth)
77
+
78
+
79
+ def _cmd_protocol_schema(args: argparse.Namespace) -> Any:
80
+ return uns.protocol_schema(args.protocol, version=args.version)
81
+
82
+
83
+ def _cmd_discover(args: argparse.Namespace) -> Any:
84
+ return uns.discover(resource_types=args.resource_types, protocols=args.protocols)
85
+
86
+
87
+ def _cmd_resource_search(args: argparse.Namespace) -> Any:
88
+ return uns.search_resources(
89
+ args.resource_type,
90
+ protocol=args.protocol,
91
+ keyword=args.keyword,
92
+ selector=_json_object(args.selector_json, "selector"),
93
+ page=args.page,
94
+ page_size=args.page_size,
95
+ )
96
+
97
+
98
+ def _cmd_bindings(_args: argparse.Namespace) -> Any:
99
+ return uns.bindings()
100
+
101
+
102
+ def _cmd_path_resolve(args: argparse.Namespace) -> Any:
103
+ return uns.resolve_path(args.path)
104
+
105
+
106
+ def _target(args: argparse.Namespace) -> tuple[str, bool]:
107
+ node_code = getattr(args, "node_code", None)
108
+ path = getattr(args, "path", None)
109
+ if bool(node_code) == bool(path):
110
+ raise argparse.ArgumentTypeError("provide exactly one node_code or --path")
111
+ return (path, True) if path else (node_code, False)
112
+
113
+
114
+ def _cmd_validate(args: argparse.Namespace) -> Any:
115
+ return uns.validate(_document(args.file))
116
+
117
+
118
+ def _cmd_plan(args: argparse.Namespace) -> Any:
119
+ return uns.plan(_document(args.file))
120
+
121
+
122
+ def _cmd_apply(args: argparse.Namespace) -> Any:
123
+ if not args.confirm:
124
+ raise argparse.ArgumentTypeError("UNS apply requires --confirm")
125
+ if not args.document_hash.strip() or not args.plan_hash.strip():
126
+ raise argparse.ArgumentTypeError("UNS apply requires non-empty document and plan hashes")
127
+ return uns.apply(
128
+ _document(args.file),
129
+ expected_document_hash=args.document_hash,
130
+ expected_plan_hash=args.plan_hash,
131
+ confirmed=True,
132
+ )
133
+
134
+
135
+ def _cmd_data_query(args: argparse.Namespace) -> Any:
136
+ target, by_path = _target(args)
137
+ handler = uns.query_path_data if by_path else uns.query_node_data
138
+ return handler(
139
+ target,
140
+ page_num=args.page_num,
141
+ page_size=args.page_size,
142
+ pagination_mode=args.pagination_mode,
143
+ page_token=args.page_token,
144
+ )
145
+
146
+
147
+ def _cmd_data_write(args: argparse.Namespace) -> Any:
148
+ if not args.confirm:
149
+ raise argparse.ArgumentTypeError("UNS data write requires --confirm")
150
+ target, by_path = _target(args)
151
+ handler = uns.write_path_data if by_path else uns.write_node_data
152
+ return handler(
153
+ target,
154
+ _json_object(args.fields_json, "field values"),
155
+ confirmed=True,
156
+ )
157
+
158
+
159
+ def _cmd_data_delete(args: argparse.Namespace) -> Any:
160
+ if not args.confirm:
161
+ raise argparse.ArgumentTypeError("UNS data deletion requires --confirm")
162
+ target, by_path = _target(args)
163
+ handler = uns.delete_path_data if by_path else uns.delete_node_data
164
+ return handler(
165
+ target,
166
+ _json_object_array(args.rows_json, "rows"),
167
+ confirmed=True,
168
+ )
169
+
170
+
171
+ def _cmd_point_current(args: argparse.Namespace) -> Any:
172
+ target, by_path = _target(args)
173
+ return uns.path_point_current(target) if by_path else uns.point_current(target)
174
+
175
+
176
+ def _cmd_point_history(args: argparse.Namespace) -> Any:
177
+ target, by_path = _target(args)
178
+ handler = uns.path_point_history if by_path else uns.point_history
179
+ return handler(
180
+ target,
181
+ start_time=args.start_time,
182
+ end_time=args.end_time,
183
+ page=args.page,
184
+ page_size=args.page_size,
185
+ )
186
+
187
+
188
+ def _cmd_point_write(args: argparse.Namespace) -> Any:
189
+ if not args.dry_run and not args.confirm:
190
+ raise argparse.ArgumentTypeError("UNS point write requires --confirm unless --dry-run is used")
191
+ target, by_path = _target(args)
192
+ handler = uns.write_path_point if by_path else uns.write_point
193
+ return handler(
194
+ target,
195
+ _json_value(args.value_json, "point value"),
196
+ reason=args.reason,
197
+ ticket=args.ticket,
198
+ request_id=args.request_id,
199
+ dry_run=args.dry_run,
200
+ confirmed=args.confirm,
201
+ )
202
+
203
+
204
+ def _cmd_video_stream(args: argparse.Namespace) -> Any:
205
+ target, by_path = _target(args)
206
+ return uns.path_video_stream_options(target) if by_path else uns.video_stream_options(target)
207
+
208
+
209
+ def _cmd_video_snapshot(args: argparse.Namespace) -> Any:
210
+ target, by_path = _target(args)
211
+ return uns.path_video_snapshot(target) if by_path else uns.video_snapshot(target)
212
+
213
+
214
+ def _cmd_video_control(args: argparse.Namespace) -> Any:
215
+ if not args.confirm:
216
+ raise argparse.ArgumentTypeError("UNS video control requires --confirm")
217
+ target, by_path = _target(args)
218
+ handler = uns.control_path_video if by_path else uns.control_video
219
+ return handler(
220
+ target,
221
+ args.action,
222
+ parameters=_json_object(args.parameters_json, "video control parameters"),
223
+ confirmed=True,
224
+ )
225
+
226
+
227
+ def _cmd_finalize(args: argparse.Namespace) -> Any:
228
+ project_root = Path(os.environ.get("LICOS_PROJECT_PATH", "/workspace/projects")).resolve()
229
+ candidate = Path(args.file).resolve()
230
+ target = project_root / "UNS.json"
231
+ if not candidate.is_relative_to(project_root):
232
+ raise argparse.ArgumentTypeError("candidate file must be inside LICOS_PROJECT_PATH")
233
+ if candidate == target:
234
+ raise argparse.ArgumentTypeError("candidate file must not already be UNS.json")
235
+
236
+ document = _document(str(candidate))
237
+ validation = uns.validate(document)
238
+ if not validation.get("valid"):
239
+ raise PlatformSdkError("candidate validation failed; inspect `licos-platform uns validate` output")
240
+
241
+ project_root.mkdir(parents=True, exist_ok=True)
242
+ previous = target.read_bytes() if target.exists() else None
243
+ staging_path: Path | None = None
244
+ target_replaced = False
245
+ try:
246
+ with tempfile.NamedTemporaryFile(
247
+ mode="wb", prefix=".UNS.json.", suffix=".tmp", dir=project_root, delete=False
248
+ ) as staging:
249
+ staging.write(candidate.read_bytes())
250
+ staging.flush()
251
+ os.fsync(staging.fileno())
252
+ staging_path = Path(staging.name)
253
+ os.replace(staging_path, target)
254
+ staging_path = None
255
+ target_replaced = True
256
+
257
+ final_document = _document(str(target))
258
+ final_validation = uns.validate(final_document)
259
+ if not final_validation.get("valid"):
260
+ raise PlatformSdkError("final UNS.json validation failed; the previous file was restored")
261
+ plan = uns.plan(final_document)
262
+ candidate.unlink(missing_ok=True)
263
+ return {
264
+ "installed": True,
265
+ "target": "UNS.json",
266
+ "validation": final_validation,
267
+ "plan": plan,
268
+ }
269
+ except BaseException:
270
+ if staging_path is not None:
271
+ staging_path.unlink(missing_ok=True)
272
+ if target_replaced:
273
+ _restore_target(target, previous)
274
+ raise
275
+
276
+
277
+ def _restore_target(target: Path, previous: bytes | None) -> None:
278
+ if previous is None:
279
+ target.unlink(missing_ok=True)
280
+ return
281
+ with tempfile.NamedTemporaryFile(
282
+ mode="wb", prefix=".UNS.restore.", suffix=".tmp", dir=target.parent, delete=False
283
+ ) as restore:
284
+ restore.write(previous)
285
+ restore.flush()
286
+ os.fsync(restore.fileno())
287
+ restore_path = Path(restore.name)
288
+ os.replace(restore_path, target)
289
+
290
+
291
+ def add_uns_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
292
+ parser = subparsers.add_parser("uns", help="Inspect, validate, plan, and apply project UNS manifests")
293
+ commands = parser.add_subparsers(dest="uns_command", required=True)
294
+
295
+ _set_handler(commands.add_parser("schema-current", help="Get the current UNS Schema"), _cmd_schema_current)
296
+
297
+ schema = commands.add_parser("schema", help="Get a complete versioned UNS Schema")
298
+ schema.add_argument("--version", default="1.0")
299
+ _set_handler(schema, _cmd_schema)
300
+
301
+ blocks = commands.add_parser("schema-blocks", help="List versioned UNS Schema blocks")
302
+ blocks.add_argument("--version", default="1.0")
303
+ _set_handler(blocks, _cmd_schema_blocks)
304
+
305
+ block = commands.add_parser("schema-block", help="Get one UNS Schema block")
306
+ block.add_argument("block_code")
307
+ block.add_argument("--version", default="1.0")
308
+ block.add_argument("--depth", type=int, default=1, choices=range(0, 4))
309
+ _set_handler(block, _cmd_schema_block)
310
+
311
+ protocol = commands.add_parser("protocol-schema", help="Get one collection-point protocol Schema")
312
+ protocol.add_argument("protocol")
313
+ protocol.add_argument("--version", default="1.0")
314
+ _set_handler(protocol, _cmd_protocol_schema)
315
+
316
+ discover = commands.add_parser("discover", help="Discover reusable project industrial resources without writes")
317
+ discover.add_argument("--resource-type", action="append", dest="resource_types", default=None)
318
+ discover.add_argument("--protocol", action="append", dest="protocols", default=None)
319
+ _set_handler(discover, _cmd_discover)
320
+
321
+ search = commands.add_parser("resource-search", help="Search reusable resources with a manifest selector")
322
+ search.add_argument("--resource-type", required=True)
323
+ search.add_argument("--protocol")
324
+ search.add_argument("--keyword")
325
+ search.add_argument("--selector-json")
326
+ search.add_argument("--page", type=int, default=1)
327
+ search.add_argument("--page-size", type=int, default=20)
328
+ _set_handler(search, _cmd_resource_search)
329
+
330
+ _set_handler(commands.add_parser("bindings", help="List project UNS business bindings"), _cmd_bindings)
331
+
332
+ path_resolve = commands.add_parser(
333
+ "path-resolve", help="Resolve an absolute or relative enterprise UNS path"
334
+ )
335
+ path_resolve.add_argument("path")
336
+ _set_handler(path_resolve, _cmd_path_resolve)
337
+
338
+ validate = commands.add_parser("validate", help="Validate one local UNS JSON document")
339
+ validate.add_argument("--file", required=True)
340
+ _set_handler(validate, _cmd_validate)
341
+
342
+ plan = commands.add_parser("plan", help="Create a read-only reconciliation plan for one UNS JSON document")
343
+ plan.add_argument("--file", required=True)
344
+ _set_handler(plan, _cmd_plan)
345
+
346
+ apply_command = commands.add_parser(
347
+ "apply", help="Apply one previously reviewed UNS reconciliation plan"
348
+ )
349
+ apply_command.add_argument("--file", required=True)
350
+ apply_command.add_argument("--document-hash", required=True)
351
+ apply_command.add_argument("--plan-hash", required=True)
352
+ apply_command.add_argument("--confirm", action="store_true")
353
+ _set_handler(apply_command, _cmd_apply)
354
+
355
+ finalize = commands.add_parser(
356
+ "finalize", help="Validate and atomically install a candidate as project UNS.json, then create a plan"
357
+ )
358
+ finalize.add_argument("--file", required=True)
359
+ _set_handler(finalize, _cmd_finalize)
360
+
361
+ data_query = commands.add_parser("data-query", help="Query stored data through a UNS node")
362
+ data_query.add_argument("node_code", nargs="?")
363
+ data_query.add_argument("--path")
364
+ data_query.add_argument("--page-num", type=int, default=1)
365
+ data_query.add_argument("--page-size", type=int, default=50)
366
+ data_query.add_argument("--pagination-mode", choices=("OFFSET", "CURSOR"), default="OFFSET")
367
+ data_query.add_argument("--page-token")
368
+ _set_handler(data_query, _cmd_data_query)
369
+
370
+ data_write = commands.add_parser("data-write", help="Write structured data through a UNS node")
371
+ data_write.add_argument("node_code", nargs="?")
372
+ data_write.add_argument("--path")
373
+ data_write.add_argument("--fields-json", required=True)
374
+ data_write.add_argument("--confirm", action="store_true")
375
+ _set_handler(data_write, _cmd_data_write)
376
+
377
+ data_delete = commands.add_parser("data-delete", help="Delete exact stored rows through a UNS node")
378
+ data_delete.add_argument("node_code", nargs="?")
379
+ data_delete.add_argument("--path")
380
+ data_delete.add_argument("--rows-json", required=True)
381
+ data_delete.add_argument("--confirm", action="store_true")
382
+ _set_handler(data_delete, _cmd_data_delete)
383
+
384
+ point_current = commands.add_parser("point-current", help="Read the current value of a UNS point")
385
+ point_current.add_argument("node_code", nargs="?")
386
+ point_current.add_argument("--path")
387
+ _set_handler(point_current, _cmd_point_current)
388
+
389
+ point_history = commands.add_parser("point-history", help="Read historical values of a UNS point")
390
+ point_history.add_argument("node_code", nargs="?")
391
+ point_history.add_argument("--path")
392
+ point_history.add_argument("--start-time", required=True)
393
+ point_history.add_argument("--end-time", required=True)
394
+ point_history.add_argument("--page", type=int, default=1)
395
+ point_history.add_argument("--page-size", type=int, default=100)
396
+ _set_handler(point_history, _cmd_point_history)
397
+
398
+ point_write = commands.add_parser("point-write", help="Write or dry-run a controllable UNS point")
399
+ point_write.add_argument("node_code", nargs="?")
400
+ point_write.add_argument("--path")
401
+ point_write.add_argument("--value-json", required=True)
402
+ point_write.add_argument("--reason", required=True)
403
+ point_write.add_argument("--ticket", required=True)
404
+ point_write.add_argument("--request-id")
405
+ point_write.add_argument("--dry-run", action="store_true")
406
+ point_write.add_argument("--confirm", action="store_true")
407
+ _set_handler(point_write, _cmd_point_write)
408
+
409
+ video_stream = commands.add_parser("video-stream", help="Get playback options for a UNS video node")
410
+ video_stream.add_argument("node_code", nargs="?")
411
+ video_stream.add_argument("--path")
412
+ _set_handler(video_stream, _cmd_video_stream)
413
+
414
+ video_snapshot = commands.add_parser("video-snapshot", help="Capture a snapshot from a UNS video node")
415
+ video_snapshot.add_argument("node_code", nargs="?")
416
+ video_snapshot.add_argument("--path")
417
+ _set_handler(video_snapshot, _cmd_video_snapshot)
418
+
419
+ video_control = commands.add_parser("video-control", help="Run an explicitly confirmed video control")
420
+ video_control.add_argument("node_code", nargs="?")
421
+ video_control.add_argument("--path")
422
+ video_control.add_argument(
423
+ "--action", required=True, choices=("move", "stop", "preset-set", "preset-goto")
424
+ )
425
+ video_control.add_argument("--parameters-json")
426
+ video_control.add_argument("--confirm", action="store_true")
427
+ _set_handler(video_control, _cmd_video_control)
@@ -14,8 +14,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
15
 
16
16
  from licos_platform_cli import main as cli_main
17
- from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
18
- from licos_platform_sdk import ApiError
17
+ from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands, uns_commands
18
+ from licos_platform_sdk import ApiError, PlatformSdkError
19
19
 
20
20
 
21
21
  def run_cli(*args: str) -> tuple[int, str, str]:
@@ -46,7 +46,7 @@ class PlatformCliTests(unittest.TestCase):
46
46
  self.assertEqual(code, 0)
47
47
  self.assertEqual(stderr, "")
48
48
  payload = json.loads(stdout)
49
- self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
49
+ self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage", "uns"])
50
50
  self.assertIn("licos-platform help storage", payload["usage"])
51
51
 
52
52
  def test_structured_help_storage_command_describes_parameters(self) -> None:
@@ -74,17 +74,321 @@ class PlatformCliTests(unittest.TestCase):
74
74
  self.assertIn("studio-ensure", payload["commands"])
75
75
  self.assertNotIn("LICOS_DATABASE_ENVIRONMENT", {item["name"] for item in payload["runtime"]})
76
76
 
77
- def test_structured_help_knowledge_topic_describes_default_search(self) -> None:
77
+ def test_structured_help_knowledge_topic_describes_default_search(self) -> None:
78
78
  code, stdout, stderr = run_cli("help", "knowledge")
79
79
 
80
80
  self.assertEqual(code, 0)
81
81
  self.assertEqual(stderr, "")
82
82
  payload = json.loads(stdout)
83
83
  self.assertEqual(payload["topic"], "knowledge")
84
- self.assertIn("search", payload["commands"])
85
- self.assertTrue(any("Search all accessible datasets by default" in rule for rule in payload["rules"]))
86
-
87
- def test_storage_files_wraps_sdk_and_outputs_json(self) -> None:
84
+ self.assertIn("search", payload["commands"])
85
+ self.assertTrue(any("Search all accessible datasets by default" in rule for rule in payload["rules"]))
86
+
87
+ def test_structured_help_uns_exposes_only_confirmed_apply(self) -> None:
88
+ code, stdout, stderr = run_cli("help", "uns")
89
+
90
+ self.assertEqual(code, 0)
91
+ self.assertEqual(stderr, "")
92
+ payload = json.loads(stdout)
93
+ self.assertIn("apply", payload["commands"])
94
+ parameters = {item["name"] for item in payload["commands"]["apply"]["parameters"]}
95
+ self.assertEqual(parameters, {"--file", "--document-hash", "--plan-hash", "--confirm"})
96
+
97
+ def test_uns_validate_loads_json_and_calls_sdk(self) -> None:
98
+ document = {"schemaVersion": "1.0", "resources": {}, "uns": {"nodes": []}}
99
+ with tempfile.TemporaryDirectory() as tmp:
100
+ path = Path(tmp) / "UNS.json"
101
+ path.write_text(json.dumps(document), encoding="utf-8")
102
+ with mock.patch.object(uns_commands.uns, "validate", return_value={"valid": True}) as validate:
103
+ code, stdout, stderr = run_cli("uns", "validate", "--file", str(path))
104
+
105
+ self.assertEqual(code, 0)
106
+ self.assertEqual(stderr, "")
107
+ self.assertEqual(json.loads(stdout), {"valid": True})
108
+ validate.assert_called_once_with(document)
109
+
110
+ def test_uns_discover_forwards_only_filters(self) -> None:
111
+ with mock.patch.object(uns_commands.uns, "discover", return_value={"resources": {}}) as discover:
112
+ code, stdout, stderr = run_cli(
113
+ "uns", "discover", "--resource-type", "connections", "--protocol", "opcua"
114
+ )
115
+
116
+ self.assertEqual(code, 0)
117
+ self.assertEqual(stderr, "")
118
+ self.assertEqual(json.loads(stdout), {"resources": {}})
119
+ discover.assert_called_once_with(resource_types=["connections"], protocols=["opcua"])
120
+
121
+ def test_uns_resource_search_forwards_structured_selector(self) -> None:
122
+ with mock.patch.object(uns_commands.uns, "search_resources", return_value={"items": []}) as search:
123
+ code, stdout, stderr = run_cli(
124
+ "uns", "resource-search",
125
+ "--resource-type", "collectionPoints",
126
+ "--protocol", "opcua",
127
+ "--selector-json", '{"tagCode":"TEMP_01"}',
128
+ )
129
+
130
+ self.assertEqual(code, 0)
131
+ self.assertEqual(stderr, "")
132
+ self.assertEqual(json.loads(stdout), {"items": []})
133
+ search.assert_called_once_with(
134
+ "collectionPoints",
135
+ protocol="opcua",
136
+ keyword=None,
137
+ selector={"tagCode": "TEMP_01"},
138
+ page=1,
139
+ page_size=20,
140
+ )
141
+
142
+ def test_uns_point_write_allows_dry_run_without_execution_confirmation(self) -> None:
143
+ with mock.patch.object(uns_commands.uns, "write_point", return_value={"dryRun": True}) as write:
144
+ code, stdout, stderr = run_cli(
145
+ "uns", "point-write", "MOTOR_START",
146
+ "--value-json", "true",
147
+ "--reason", "validate control",
148
+ "--ticket", "WO-100",
149
+ "--dry-run",
150
+ )
151
+
152
+ self.assertEqual(code, 0)
153
+ self.assertEqual(stderr, "")
154
+ self.assertEqual(json.loads(stdout), {"dryRun": True})
155
+ write.assert_called_once_with(
156
+ "MOTOR_START",
157
+ True,
158
+ reason="validate control",
159
+ ticket="WO-100",
160
+ request_id=None,
161
+ dry_run=True,
162
+ confirmed=False,
163
+ )
164
+
165
+ def test_uns_path_resolve_calls_sdk_without_creating_a_manifest(self) -> None:
166
+ path = "workspace:/project/project-2/FACTORY/TEMP"
167
+ with mock.patch.object(
168
+ uns_commands.uns,
169
+ "resolve_path",
170
+ return_value={"canonicalPath": "uns://enterprise/tenant-1/organization/org-1/"
171
+ "workspace/workspace-1/project/project-2/FACTORY/TEMP"},
172
+ ) as resolve:
173
+ code, stdout, stderr = run_cli("uns", "path-resolve", path)
174
+
175
+ self.assertEqual(code, 0)
176
+ self.assertEqual(stderr, "")
177
+ self.assertIn("canonicalPath", json.loads(stdout))
178
+ resolve.assert_called_once_with(path)
179
+
180
+ def test_uns_data_query_accepts_enterprise_path_instead_of_node_code(self) -> None:
181
+ path = "enterprise:/organization/org-1/workspace/workspace-2/project/project-2/ORDERS"
182
+ with mock.patch.object(
183
+ uns_commands.uns, "query_path_data", return_value={"rows": []}
184
+ ) as query:
185
+ code, stdout, stderr = run_cli("uns", "data-query", "--path", path)
186
+
187
+ self.assertEqual(code, 0)
188
+ self.assertEqual(stderr, "")
189
+ self.assertEqual(json.loads(stdout), {"rows": []})
190
+ query.assert_called_once_with(
191
+ path,
192
+ page_num=1,
193
+ page_size=50,
194
+ pagination_mode="OFFSET",
195
+ page_token=None,
196
+ )
197
+
198
+ def test_uns_data_query_rejects_node_code_and_path_together(self) -> None:
199
+ with mock.patch.object(uns_commands.uns, "query_path_data") as query:
200
+ code, stdout, stderr = run_cli(
201
+ "uns", "data-query", "ORDERS", "--path", "project:/ORDERS"
202
+ )
203
+
204
+ self.assertEqual(code, 2)
205
+ self.assertEqual(stdout, "")
206
+ self.assertIn("exactly one", stderr)
207
+ query.assert_not_called()
208
+
209
+ def test_uns_data_delete_requires_confirmation_and_forwards_rows(self) -> None:
210
+ rows = [{"recordId": "row-1", "amount": 12}]
211
+ with mock.patch.object(
212
+ uns_commands.uns, "delete_node_data", return_value={"deletedCount": 1}
213
+ ) as delete:
214
+ code, stdout, stderr = run_cli(
215
+ "uns", "data-delete", "ORDERS",
216
+ "--rows-json", json.dumps(rows),
217
+ "--confirm",
218
+ )
219
+
220
+ self.assertEqual(code, 0)
221
+ self.assertEqual(stderr, "")
222
+ self.assertEqual(json.loads(stdout), {"deletedCount": 1})
223
+ delete.assert_called_once_with("ORDERS", rows, confirmed=True)
224
+
225
+ def test_uns_data_delete_does_not_call_sdk_without_confirmation(self) -> None:
226
+ with mock.patch.object(uns_commands.uns, "delete_node_data") as delete:
227
+ code, stdout, stderr = run_cli(
228
+ "uns", "data-delete", "ORDERS",
229
+ "--rows-json", '[{"recordId":"row-1"}]',
230
+ )
231
+
232
+ self.assertEqual(code, 2)
233
+ self.assertEqual(stdout, "")
234
+ self.assertIn("requires --confirm", stderr)
235
+ delete.assert_not_called()
236
+
237
+ def test_uns_apply_requires_confirmation_and_forwards_reviewed_hashes(self) -> None:
238
+ document = {"schemaVersion": "1.0", "resources": {}, "uns": {"nodes": []}}
239
+ with tempfile.TemporaryDirectory() as tmp:
240
+ path = Path(tmp) / "UNS.json"
241
+ path.write_text(json.dumps(document), encoding="utf-8")
242
+ with mock.patch.object(uns_commands.uns, "apply", return_value={"status": "SUCCESS"}) as apply:
243
+ code, stdout, stderr = run_cli(
244
+ "uns", "apply",
245
+ "--file", str(path),
246
+ "--document-hash", "document-hash",
247
+ "--plan-hash", "plan-hash",
248
+ "--confirm",
249
+ )
250
+
251
+ self.assertEqual(code, 0)
252
+ self.assertEqual(stderr, "")
253
+ self.assertEqual(json.loads(stdout), {"status": "SUCCESS"})
254
+ apply.assert_called_once_with(
255
+ document,
256
+ expected_document_hash="document-hash",
257
+ expected_plan_hash="plan-hash",
258
+ confirmed=True,
259
+ )
260
+
261
+ def test_uns_apply_does_not_write_without_confirmation(self) -> None:
262
+ document = {"schemaVersion": "1.0", "resources": {}, "uns": {"nodes": []}}
263
+ with tempfile.TemporaryDirectory() as tmp:
264
+ path = Path(tmp) / "UNS.json"
265
+ path.write_text(json.dumps(document), encoding="utf-8")
266
+ with mock.patch.object(uns_commands.uns, "apply") as apply:
267
+ code, stdout, stderr = run_cli(
268
+ "uns", "apply",
269
+ "--file", str(path),
270
+ "--document-hash", "document-hash",
271
+ "--plan-hash", "plan-hash",
272
+ )
273
+
274
+ self.assertEqual(code, 2)
275
+ self.assertEqual(stdout, "")
276
+ self.assertIn("requires --confirm", stderr)
277
+ apply.assert_not_called()
278
+
279
+ def test_uns_finalize_atomically_replaces_target_after_validation(self) -> None:
280
+ document = {"schemaVersion": "1.0", "resources": {}, "uns": {"nodes": []}}
281
+ with tempfile.TemporaryDirectory() as tmp:
282
+ root = Path(tmp)
283
+ candidate = root / ".UNS.json.candidate"
284
+ candidate.write_text(json.dumps(document), encoding="utf-8")
285
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
286
+ with mock.patch.object(uns_commands.uns, "validate", return_value={"valid": True}) as validate:
287
+ with mock.patch.object(uns_commands.uns, "plan", return_value={"planHash": "hash"}) as plan:
288
+ code, stdout, stderr = run_cli("uns", "finalize", "--file", str(candidate))
289
+
290
+ self.assertEqual(code, 0)
291
+ self.assertEqual(stderr, "")
292
+ self.assertFalse(candidate.exists())
293
+ self.assertEqual(json.loads((root / "UNS.json").read_text(encoding="utf-8")), document)
294
+ self.assertEqual(json.loads(stdout)["plan"], {"planHash": "hash"})
295
+ self.assertEqual(validate.call_count, 2)
296
+ plan.assert_called_once_with(document)
297
+
298
+ def test_uns_finalize_keeps_existing_file_when_candidate_is_invalid(self) -> None:
299
+ existing = {"schemaVersion": "1.0", "metadata": {"name": "existing"}}
300
+ candidate_document = {"schemaVersion": "1.0", "metadata": {"name": "candidate"}}
301
+ with tempfile.TemporaryDirectory() as tmp:
302
+ root = Path(tmp)
303
+ target = root / "UNS.json"
304
+ candidate = root / ".UNS.json.candidate"
305
+ target.write_text(json.dumps(existing), encoding="utf-8")
306
+ candidate.write_text(json.dumps(candidate_document), encoding="utf-8")
307
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
308
+ with mock.patch.object(uns_commands.uns, "validate", return_value={"valid": False}):
309
+ with mock.patch.object(uns_commands.uns, "plan") as plan:
310
+ code, stdout, stderr = run_cli("uns", "finalize", "--file", str(candidate))
311
+
312
+ self.assertEqual(code, 1)
313
+ self.assertEqual(stdout, "")
314
+ self.assertIn("candidate validation failed", stderr)
315
+ self.assertEqual(json.loads(target.read_text(encoding="utf-8")), existing)
316
+ self.assertTrue(candidate.exists())
317
+ plan.assert_not_called()
318
+
319
+ def test_uns_finalize_restores_existing_file_when_final_validation_fails(self) -> None:
320
+ existing = {"schemaVersion": "1.0", "metadata": {"name": "existing"}}
321
+ candidate_document = {"schemaVersion": "1.0", "metadata": {"name": "candidate"}}
322
+ with tempfile.TemporaryDirectory() as tmp:
323
+ root = Path(tmp)
324
+ target = root / "UNS.json"
325
+ candidate = root / ".UNS.json.candidate"
326
+ target.write_text(json.dumps(existing), encoding="utf-8")
327
+ candidate.write_text(json.dumps(candidate_document), encoding="utf-8")
328
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
329
+ with mock.patch.object(
330
+ uns_commands.uns,
331
+ "validate",
332
+ side_effect=[{"valid": True}, {"valid": False}],
333
+ ):
334
+ with mock.patch.object(uns_commands.uns, "plan") as plan:
335
+ code, stdout, stderr = run_cli("uns", "finalize", "--file", str(candidate))
336
+
337
+ self.assertEqual(code, 1)
338
+ self.assertEqual(stdout, "")
339
+ self.assertIn("previous file was restored", stderr)
340
+ self.assertEqual(json.loads(target.read_text(encoding="utf-8")), existing)
341
+ self.assertTrue(candidate.exists())
342
+ plan.assert_not_called()
343
+
344
+ def test_uns_finalize_restores_existing_file_when_final_validation_raises(self) -> None:
345
+ existing = {"schemaVersion": "1.0", "metadata": {"name": "existing"}}
346
+ candidate_document = {"schemaVersion": "1.0", "metadata": {"name": "candidate"}}
347
+ with tempfile.TemporaryDirectory() as tmp:
348
+ root = Path(tmp)
349
+ target = root / "UNS.json"
350
+ candidate = root / ".UNS.json.candidate"
351
+ target.write_text(json.dumps(existing), encoding="utf-8")
352
+ candidate.write_text(json.dumps(candidate_document), encoding="utf-8")
353
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
354
+ with mock.patch.object(
355
+ uns_commands.uns,
356
+ "validate",
357
+ side_effect=[{"valid": True}, PlatformSdkError("validation unavailable")],
358
+ ):
359
+ code, stdout, stderr = run_cli("uns", "finalize", "--file", str(candidate))
360
+
361
+ self.assertEqual(code, 1)
362
+ self.assertEqual(stdout, "")
363
+ self.assertIn("validation unavailable", stderr)
364
+ self.assertEqual(json.loads(target.read_text(encoding="utf-8")), existing)
365
+ self.assertTrue(candidate.exists())
366
+
367
+ def test_uns_finalize_restores_existing_file_when_plan_raises(self) -> None:
368
+ existing = {"schemaVersion": "1.0", "metadata": {"name": "existing"}}
369
+ candidate_document = {"schemaVersion": "1.0", "metadata": {"name": "candidate"}}
370
+ with tempfile.TemporaryDirectory() as tmp:
371
+ root = Path(tmp)
372
+ target = root / "UNS.json"
373
+ candidate = root / ".UNS.json.candidate"
374
+ target.write_text(json.dumps(existing), encoding="utf-8")
375
+ candidate.write_text(json.dumps(candidate_document), encoding="utf-8")
376
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
377
+ with mock.patch.object(uns_commands.uns, "validate", return_value={"valid": True}):
378
+ with mock.patch.object(
379
+ uns_commands.uns,
380
+ "plan",
381
+ side_effect=PlatformSdkError("planning unavailable"),
382
+ ):
383
+ code, stdout, stderr = run_cli("uns", "finalize", "--file", str(candidate))
384
+
385
+ self.assertEqual(code, 1)
386
+ self.assertEqual(stdout, "")
387
+ self.assertIn("planning unavailable", stderr)
388
+ self.assertEqual(json.loads(target.read_text(encoding="utf-8")), existing)
389
+ self.assertTrue(candidate.exists())
390
+
391
+ def test_storage_files_wraps_sdk_and_outputs_json(self) -> None:
88
392
  with mock.patch.object(storage_commands.storage, "list_files", return_value=[{"id": "file-1"}]) as list_files:
89
393
  code, stdout, stderr = run_cli("storage", "files")
90
394
 
@@ -291,12 +595,12 @@ class PlatformCliTests(unittest.TestCase):
291
595
  "clientId": "client-3",
292
596
  }
293
597
  with tempfile.TemporaryDirectory() as tmp:
294
- env = {
295
- "LICOS_PROJECT_PATH": tmp,
296
- "AGENT_PROJECT_ID": "project/1",
297
- "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
298
- "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
299
- }
598
+ env = {
599
+ "LICOS_PROJECT_PATH": tmp,
600
+ "AGENT_PROJECT_ID": "project/1",
601
+ "LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
602
+ "LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
603
+ }
300
604
  with mock.patch.dict(os.environ, env, clear=False):
301
605
  with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
302
606
  code, stdout, stderr = run_cli(
@@ -332,8 +636,8 @@ class PlatformCliTests(unittest.TestCase):
332
636
  )
333
637
  config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
334
638
  self.assertEqual(config["redirectUriConfigs"], redirects)
335
- self.assertEqual(config["publicBaseUrl"], "https://platform.example")
336
- self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
639
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
640
+ self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
337
641
 
338
642
  def test_oauth_configure_without_redirect_requires_stable_project_metadata(self) -> None:
339
643
  with tempfile.TemporaryDirectory() as tmp: