@uipath/maestro-builder-sdk 5.2.0

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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/dist/bindings-v2.d.ts +37 -0
  4. package/dist/bindings-v2.js +155 -0
  5. package/dist/bindings.d.ts +6 -0
  6. package/dist/bindings.js +6 -0
  7. package/dist/bpmn/bpmn-expr-check.d.ts +41 -0
  8. package/dist/bpmn/bpmn-expr-check.js +218 -0
  9. package/dist/bpmn/bpmn-sdk.d.ts +2257 -0
  10. package/dist/bpmn/bpmn-sdk.js +1476 -0
  11. package/dist/bpmn/check-cli.d.ts +3 -0
  12. package/dist/bpmn/check-cli.js +32 -0
  13. package/dist/bpmn/check.d.ts +25 -0
  14. package/dist/bpmn/check.js +430 -0
  15. package/dist/bpmn/compile-cli.d.ts +3 -0
  16. package/dist/bpmn/compile-cli.js +60 -0
  17. package/dist/bpmn/connector.d.ts +56 -0
  18. package/dist/bpmn/connector.js +199 -0
  19. package/dist/bpmn/decompile-cli.d.ts +8 -0
  20. package/dist/bpmn/decompile-cli.js +54 -0
  21. package/dist/bpmn/decompile.d.ts +6 -0
  22. package/dist/bpmn/decompile.js +1108 -0
  23. package/dist/bpmn/format-profile.d.ts +27 -0
  24. package/dist/bpmn/format-profile.js +11 -0
  25. package/dist/bpmn/intsvc.d.ts +77 -0
  26. package/dist/bpmn/intsvc.js +155 -0
  27. package/dist/bpmn/load.d.ts +4 -0
  28. package/dist/bpmn/load.js +22 -0
  29. package/dist/bpmn/merge-cli.d.ts +3 -0
  30. package/dist/bpmn/merge-cli.js +45 -0
  31. package/dist/bpmn/merge.d.ts +19 -0
  32. package/dist/bpmn/merge.js +293 -0
  33. package/dist/bpmn/registry-types.json +1869 -0
  34. package/dist/bpmn/serialize.d.ts +28 -0
  35. package/dist/bpmn/serialize.js +757 -0
  36. package/dist/bpmn/tidy-cli.d.ts +2 -0
  37. package/dist/bpmn/tidy-cli.js +39 -0
  38. package/dist/bpmn/typed-node.d.ts +382 -0
  39. package/dist/bpmn/typed-node.js +551 -0
  40. package/dist/bpmn/uipath-moddle.v1.json +823 -0
  41. package/dist/bpmn/xml-ranges.d.ts +45 -0
  42. package/dist/bpmn/xml-ranges.js +151 -0
  43. package/dist/case/case-expr-check.d.ts +35 -0
  44. package/dist/case/case-expr-check.js +85 -0
  45. package/dist/case/case-sdk.d.ts +1282 -0
  46. package/dist/case/case-sdk.js +923 -0
  47. package/dist/case/check-cli.d.ts +3 -0
  48. package/dist/case/check-cli.js +31 -0
  49. package/dist/case/check.d.ts +18 -0
  50. package/dist/case/check.js +456 -0
  51. package/dist/case/compile-cli.d.ts +3 -0
  52. package/dist/case/compile-cli.js +291 -0
  53. package/dist/case/decompile-cli.d.ts +9 -0
  54. package/dist/case/decompile-cli.js +117 -0
  55. package/dist/case/decompile.d.ts +187 -0
  56. package/dist/case/decompile.js +1170 -0
  57. package/dist/case/entry-points.d.ts +79 -0
  58. package/dist/case/entry-points.js +118 -0
  59. package/dist/case/format-profile.d.ts +60 -0
  60. package/dist/case/format-profile.js +79 -0
  61. package/dist/case/ids.d.ts +24 -0
  62. package/dist/case/ids.js +76 -0
  63. package/dist/case/load.d.ts +12 -0
  64. package/dist/case/load.js +32 -0
  65. package/dist/case/preserve.d.ts +133 -0
  66. package/dist/case/preserve.js +105 -0
  67. package/dist/case/serialize.d.ts +10 -0
  68. package/dist/case/serialize.js +1414 -0
  69. package/dist/check-cli.d.ts +3 -0
  70. package/dist/check-cli.js +118 -0
  71. package/dist/check.d.ts +146 -0
  72. package/dist/check.js +4898 -0
  73. package/dist/cli/commands/bpmn.d.ts +2 -0
  74. package/dist/cli/commands/bpmn.js +73 -0
  75. package/dist/cli/commands/case.d.ts +2 -0
  76. package/dist/cli/commands/case.js +60 -0
  77. package/dist/cli/commands/flow.d.ts +2 -0
  78. package/dist/cli/commands/flow.js +99 -0
  79. package/dist/cli/commands/registry.d.ts +10 -0
  80. package/dist/cli/commands/registry.js +170 -0
  81. package/dist/cli/common.d.ts +30 -0
  82. package/dist/cli/common.js +92 -0
  83. package/dist/cli/index.d.ts +2 -0
  84. package/dist/cli/index.js +18 -0
  85. package/dist/cli-run.d.ts +2 -0
  86. package/dist/cli-run.js +42 -0
  87. package/dist/compile-cli.d.ts +3 -0
  88. package/dist/compile-cli.js +160 -0
  89. package/dist/config.d.ts +47 -0
  90. package/dist/config.js +201 -0
  91. package/dist/core/actions.d.ts +3244 -0
  92. package/dist/core/actions.js +1062 -0
  93. package/dist/core/binding-messages.d.ts +1 -0
  94. package/dist/core/binding-messages.js +16 -0
  95. package/dist/core/bindings.d.ts +12 -0
  96. package/dist/core/bindings.js +67 -0
  97. package/dist/core/ceql.d.ts +73 -0
  98. package/dist/core/ceql.js +272 -0
  99. package/dist/core/cli-spelling.d.ts +39 -0
  100. package/dist/core/cli-spelling.js +62 -0
  101. package/dist/core/connector-raw-node.d.ts +21 -0
  102. package/dist/core/connector-raw-node.js +47 -0
  103. package/dist/core/connectors.d.ts +184 -0
  104. package/dist/core/connectors.js +128 -0
  105. package/dist/core/expr-check.d.ts +93 -0
  106. package/dist/core/expr-check.js +186 -0
  107. package/dist/core/expr.d.ts +323 -0
  108. package/dist/core/expr.js +400 -0
  109. package/dist/core/index.d.ts +10 -0
  110. package/dist/core/index.js +10 -0
  111. package/dist/core/library.d.ts +440 -0
  112. package/dist/core/library.js +1183 -0
  113. package/dist/core/list-envelope.d.ts +45 -0
  114. package/dist/core/list-envelope.js +97 -0
  115. package/dist/core/lookup-store.d.ts +22 -0
  116. package/dist/core/lookup-store.js +85 -0
  117. package/dist/core/lookups.d.ts +227 -0
  118. package/dist/core/lookups.js +231 -0
  119. package/dist/core/node-classes.d.ts +234 -0
  120. package/dist/core/node-classes.js +158 -0
  121. package/dist/core/script-return.d.ts +68 -0
  122. package/dist/core/script-return.js +197 -0
  123. package/dist/core/stable-id.d.ts +28 -0
  124. package/dist/core/stable-id.js +37 -0
  125. package/dist/core-definitions.json +16249 -0
  126. package/dist/decompile-cli.d.ts +3 -0
  127. package/dist/decompile-cli.js +131 -0
  128. package/dist/decompile.d.ts +138 -0
  129. package/dist/decompile.js +2482 -0
  130. package/dist/emit.d.ts +10 -0
  131. package/dist/emit.js +60 -0
  132. package/dist/event-filters.d.ts +130 -0
  133. package/dist/event-filters.js +137 -0
  134. package/dist/flow-expr-check.d.ts +17 -0
  135. package/dist/flow-expr-check.js +193 -0
  136. package/dist/flow-sdk.d.ts +1341 -0
  137. package/dist/flow-sdk.js +1164 -0
  138. package/dist/flow-variables.d.ts +13 -0
  139. package/dist/flow-variables.js +42 -0
  140. package/dist/format-profile.d.ts +72 -0
  141. package/dist/format-profile.js +97 -0
  142. package/dist/generators/_connections.py +287 -0
  143. package/dist/generators/_events.py +355 -0
  144. package/dist/generators/_lookups.py +114 -0
  145. package/dist/generators/_naming.py +185 -0
  146. package/dist/generators/_objects.py +299 -0
  147. package/dist/generators/_resolve.py +273 -0
  148. package/dist/generators/convert_library_to_md.py +1441 -0
  149. package/dist/generators/extract_library.py +125 -0
  150. package/dist/generators/generate_connectors_ts.py +936 -0
  151. package/dist/generators/generate_library_json.py +2527 -0
  152. package/dist/generators/generate_triggers_ts.py +110 -0
  153. package/dist/generators/materialize_registry.py +155 -0
  154. package/dist/generators/prepare_connector.py +1688 -0
  155. package/dist/index.d.ts +35 -0
  156. package/dist/index.js +35 -0
  157. package/dist/ixp-definition.json +1982 -0
  158. package/dist/library.d.ts +6 -0
  159. package/dist/library.js +6 -0
  160. package/dist/load.d.ts +16 -0
  161. package/dist/load.js +95 -0
  162. package/dist/merge-cli.d.ts +3 -0
  163. package/dist/merge-cli.js +47 -0
  164. package/dist/merge.d.ts +125 -0
  165. package/dist/merge.js +0 -0
  166. package/dist/migrate.d.ts +15 -0
  167. package/dist/migrate.js +245 -0
  168. package/dist/node-runtime.d.ts +3 -0
  169. package/dist/node-runtime.js +22 -0
  170. package/dist/package-name.d.ts +17 -0
  171. package/dist/package-name.js +17 -0
  172. package/dist/ref-resolve.d.ts +77 -0
  173. package/dist/ref-resolve.js +170 -0
  174. package/dist/registry/cache.d.ts +55 -0
  175. package/dist/registry/cache.js +134 -0
  176. package/dist/registry/curated-overlay.d.ts +16 -0
  177. package/dist/registry/curated-overlay.js +62 -0
  178. package/dist/registry/extractor.d.ts +22 -0
  179. package/dist/registry/extractor.js +18 -0
  180. package/dist/registry/materialize-bin.d.ts +2 -0
  181. package/dist/registry/materialize-bin.js +7 -0
  182. package/dist/registry/materialize-cli.d.ts +1 -0
  183. package/dist/registry/materialize-cli.js +46 -0
  184. package/dist/registry/materializer.d.ts +23 -0
  185. package/dist/registry/materializer.js +34 -0
  186. package/dist/registry/path-cli.d.ts +1 -0
  187. package/dist/registry/path-cli.js +56 -0
  188. package/dist/registry/prepare-cli.d.ts +62 -0
  189. package/dist/registry/prepare-cli.js +185 -0
  190. package/dist/registry/pull-cli.d.ts +1 -0
  191. package/dist/registry/pull-cli.js +274 -0
  192. package/dist/registry/python.d.ts +26 -0
  193. package/dist/registry/python.js +46 -0
  194. package/dist/registry/search-cli.d.ts +52 -0
  195. package/dist/registry/search-cli.js +159 -0
  196. package/dist/schema-version.d.ts +165 -0
  197. package/dist/schema-version.js +148 -0
  198. package/dist/serialize.d.ts +64 -0
  199. package/dist/serialize.js +6398 -0
  200. package/lib/README.md +112 -0
  201. package/lib/library-json.samples/index.json +158 -0
  202. package/lib/library-json.samples/invariant-collections.json +83 -0
  203. package/lib/library-json.samples/list-envelopes.json +123 -0
  204. package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.json +131 -0
  205. package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.v1def.json +306 -0
  206. package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.json +86 -0
  207. package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.v1def.json +208 -0
  208. package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.json +2040 -0
  209. package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.v1def.json +195 -0
  210. package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.json +169 -0
  211. package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.v1def.json +209 -0
  212. package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.json +63 -0
  213. package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.v1def.json +262 -0
  214. package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.json +63 -0
  215. package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.v1def.json +213 -0
  216. package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.json +227 -0
  217. package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.v1def.json +373 -0
  218. package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.json +116 -0
  219. package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.v1def.json +369 -0
  220. package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.json +56 -0
  221. package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.v1def.json +256 -0
  222. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.json +116 -0
  223. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.v1def.json +320 -0
  224. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.json +56 -0
  225. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.v1def.json +207 -0
  226. package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.json +107 -0
  227. package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.v1def.json +264 -0
  228. package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.json +142 -0
  229. package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.v1def.json +306 -0
  230. package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.json +107 -0
  231. package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.v1def.json +217 -0
  232. package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.json +85 -0
  233. package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.v1def.json +209 -0
  234. package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.json +755 -0
  235. package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.v1def.json +513 -0
  236. package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.json +122 -0
  237. package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.v1def.json +227 -0
  238. package/package.json +85 -0
@@ -0,0 +1,1688 @@
1
+ #!/usr/bin/env python3
2
+ """prepare-connector — fetch ONE connector op's FULL, connection-resolved schema
3
+ into an ephemeral `./connectors-local/` overlay + a typed `.ts` (C4 / design §E).
4
+
5
+ The baked library is tenant-agnostic, so schema-dynamic ops (Jira create-issue,
6
+ Salesforce, …) ship only their connection-independent fields — the compiler then
7
+ rejects the real fields (`fields.summary`, …) as unknown. `prepare-connector`
8
+ closes that at author time: with a live connection it fetches the complete field
9
+ schema for ONE op and writes the same artifacts the bulk generator produces, into
10
+ a local overlay dir the compile CLIs auto-detect (`core/library.ts` overlay).
11
+
12
+ prepare_connector.py <key> <action> --connection-id <id> [--object <name>]
13
+ [--all-objects] [-f NAME=VALUE ...] [--out ./connectors-local]
14
+
15
+ Schema-dynamic ops resolve their fields from the VALUES of a few parent fields,
16
+ not from the connection alone, so `-f` is what actually materializes them (Jira
17
+ create-issue: 2 fields with no `-f`, 19 with project + issue type, a different
18
+ 22 for a different issue type). The values themselves have to be discovered
19
+ first — that loop is the connector guide's, not this tool's.
20
+
21
+ It's a single-op, connection-scoped slice of `generate_library_json.py`: it reuses
22
+ that module's `registry_get` → `extract_canonical` → `enrich_entry` → `write_library`
23
+ (routing all uip calls through `--connection-id` via `set_connection_id`), then
24
+ runs `generate_connectors_ts.py` over the overlay. Calls ACCUMULATE — each prepared
25
+ op merges into `--out` rather than replacing it.
26
+
27
+ Needs a live connection + `uip`; it targets REAL/LIVE authoring (and the live eval
28
+ rung), not the pure-offline arm. Custom (instance-specific) fields captured from
29
+ one connection may not exist on another tenant at deploy — inherent to
30
+ schema-dynamic connectors.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import os
37
+ import re
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ from pathlib import Path
43
+
44
+ HERE = Path(__file__).resolve().parent
45
+ sys.path.insert(0, str(HERE))
46
+ import generate_library_json as gen # noqa: E402
47
+ import generate_connectors_ts as gen_ts # noqa: E402
48
+ import _resolve # noqa: E402
49
+ import _connections # noqa: E402
50
+ import _events # noqa: E402
51
+ import _objects # noqa: E402
52
+ from _lookups import load_invariant_collections # noqa: E402
53
+
54
+
55
+ def _variant_key(entry: dict) -> tuple[str, str, str]:
56
+ """Identity for one resolvable activity schema variant.
57
+
58
+ Generic object variants intentionally share a nodeType, so keying only by
59
+ nodeType makes the last prepared object overwrite every earlier one.
60
+ """
61
+ return (
62
+ entry.get("nodeType", ""),
63
+ entry.get("version", ""),
64
+ entry.get("objectName", ""),
65
+ )
66
+
67
+
68
+ def _descriptor_overlay_path(path: str) -> str:
69
+ parts = Path(path).parts
70
+ if parts and parts[0] == "descriptors":
71
+ return Path(*parts).as_posix()
72
+ return (Path("descriptors") / Path(*parts)).as_posix()
73
+
74
+
75
+ def _migrate_legacy_descriptor_roots(dst: str, entries: list[dict]) -> None:
76
+ legacy_roots = {
77
+ Path(entry["path"]).parts[0]
78
+ for entry in entries
79
+ if entry.get("path") and Path(entry["path"]).parts[0] != "descriptors"
80
+ }
81
+ for legacy_root in legacy_roots:
82
+ source_root = Path(dst) / legacy_root
83
+ if not source_root.is_dir():
84
+ continue
85
+ destination_root = Path(dst) / "descriptors" / legacy_root
86
+ for root, dirs, files in os.walk(source_root, topdown=False):
87
+ relative_root = Path(root).relative_to(source_root)
88
+ destination = destination_root / relative_root
89
+ destination.mkdir(parents=True, exist_ok=True)
90
+ for name in files:
91
+ os.replace(Path(root) / name, destination / name)
92
+ for name in dirs:
93
+ (Path(root) / name).rmdir()
94
+ source_root.rmdir()
95
+
96
+
97
+ def merge_into(src: str, dst: str) -> None:
98
+ """Merge a freshly-written single-op library `src` into the overlay `dst`,
99
+ accumulating index entries by operation/object variant and copying files."""
100
+ os.makedirs(dst, exist_ok=True)
101
+ with open(os.path.join(src, "index.json")) as src_index:
102
+ src_idx = json.load(src_index)
103
+ dst_idx_path = os.path.join(dst, "index.json")
104
+ if os.path.exists(dst_idx_path):
105
+ with open(dst_idx_path) as dst_index:
106
+ dst_idx = json.load(dst_index)
107
+ else:
108
+ dst_idx = {"schemaVersion": gen.SCHEMA_VERSION, "entries": []}
109
+ dst_entries = dst_idx.get("entries", [])
110
+ _migrate_legacy_descriptor_roots(dst, dst_entries)
111
+ for entry in dst_entries:
112
+ entry["path"] = _descriptor_overlay_path(entry["path"])
113
+ by_variant = {
114
+ _variant_key(e): e for e in dst_entries
115
+ }
116
+ for e in src_idx.get("entries", []):
117
+ # Re-preparing the same object refreshes it; preparing a different object
118
+ # accumulates a sibling variant under the shared generic nodeType.
119
+ e["path"] = _descriptor_overlay_path(e["path"])
120
+ by_variant[_variant_key(e)] = e
121
+ dst_idx["entries"] = sorted(
122
+ by_variant.values(),
123
+ key=lambda e: (
124
+ e.get("connectorKey", ""),
125
+ e.get("nodeType", ""),
126
+ e.get("version", ""),
127
+ e.get("objectName", ""),
128
+ e.get("path", ""),
129
+ ),
130
+ )
131
+ with open(dst_idx_path, "w") as f:
132
+ json.dump(dst_idx, f, indent=2)
133
+ for root, _dirs, files in os.walk(src):
134
+ for name in files:
135
+ if name == "index.json":
136
+ continue
137
+ rel = os.path.relpath(os.path.join(root, name), src)
138
+ rel = _descriptor_overlay_path(rel)
139
+ out = os.path.join(dst, rel)
140
+ os.makedirs(os.path.dirname(out), exist_ok=True)
141
+ shutil.copyfile(os.path.join(root, name), out)
142
+
143
+
144
+ def _missing_generic_schema_message(
145
+ operation: str, object_name: str | None = None
146
+ ) -> str:
147
+ target = (
148
+ f'object "{object_name}" was not found or does not support action '
149
+ f'"{operation}"'
150
+ if object_name
151
+ else f'no object in the connected catalog supports action "{operation}"'
152
+ )
153
+ return (
154
+ f"prepare-connector: {target}. A connection-resolved schema requires a "
155
+ "working live connection id. For an offline/validate-only flow that does "
156
+ "not need connection-specific fields, use the baked connector descriptor "
157
+ "without custom fields instead."
158
+ )
159
+
160
+
161
+ def _object_action_specs(registry_path: str) -> list[tuple[str, list[str]]]:
162
+ """Return each api-type ObjectAction and its ordered parent-field names."""
163
+ try:
164
+ with open(registry_path) as registry_file:
165
+ node = json.load(registry_file)["Data"]["Node"]
166
+ actions = (
167
+ (node.get("connectorMethodInfo") or {})
168
+ .get("design") or {}
169
+ ).get("actions") or []
170
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
171
+ return []
172
+
173
+ specs = []
174
+ for action in actions:
175
+ if not isinstance(action, dict) or action.get("actionType") != "api":
176
+ continue
177
+ action_name = action.get("name")
178
+ if not isinstance(action_name, str) or not action_name:
179
+ continue
180
+ refs = [
181
+ rule.get("refFieldName")
182
+ for rule in (action.get("rules") or [])
183
+ if not rule.get("isCleared") and rule.get("refFieldName")
184
+ ]
185
+ if refs:
186
+ specs.append((action_name, refs))
187
+ return specs
188
+
189
+
190
+ def _object_action_request(
191
+ registry_path: str, parent_fields: dict[str, str]
192
+ ) -> dict | None:
193
+ """Build the design-time schema replay request for a prepared operation.
194
+
195
+ The connection-resolved field list is only half of a schema-dynamic
196
+ connector configuration. Studio Web also persists the ObjectAction and the
197
+ parent values that produced that field list so it can replay the same
198
+ schema. The registry owns the action name and rule order; ``-f`` owns the
199
+ values.
200
+ """
201
+ if not parent_fields:
202
+ return None
203
+ for action_name, refs in _object_action_specs(registry_path):
204
+ if not refs or set(refs) != set(parent_fields):
205
+ continue
206
+ return {
207
+ "objectActionName": action_name,
208
+ "parameterValues": [
209
+ [name.replace(".", "_sub_"), parent_fields[name]]
210
+ for name in refs
211
+ ],
212
+ }
213
+ return None
214
+ def _registry_actions(connector_key: str) -> list[str]:
215
+ """The action ids the REGISTRY serves for one connector, newest catalog.
216
+
217
+ `registry search` rather than a cached index: this runs only on the failure
218
+ path, where the whole question is what the tenant actually serves.
219
+ """
220
+ result = subprocess.run(
221
+ ["uip", "maestro", "flow", "registry", "search", connector_key, "--output", "json"],
222
+ capture_output=True,
223
+ text=True,
224
+ )
225
+ if result.returncode != 0:
226
+ return []
227
+ try:
228
+ data = json.loads(result.stdout).get("Data") or []
229
+ except json.JSONDecodeError:
230
+ return []
231
+ prefix = f"uipath.connector.{connector_key}."
232
+ return sorted(
233
+ {
234
+ node_type[len(prefix):]
235
+ for entry in data
236
+ if isinstance(entry, dict)
237
+ for node_type in [entry.get("NodeType") or ""]
238
+ # Exact prefix only: the same search returns
239
+ # `uipath.agent.resource.tool.connector.<key>.<action>` and
240
+ # `uipath.connector.trigger.<key>.<event>`, which are not actions of
241
+ # this node and would read as alternatives an author could pass.
242
+ if node_type.startswith(prefix) and "." not in node_type[len(prefix):]
243
+ }
244
+ )
245
+
246
+
247
+ def _closest_actions(action: str, candidates: list[str]) -> list[str]:
248
+ """Candidates ranked by shared hyphen tokens, best first.
249
+
250
+ Token overlap rather than edit distance because the two namespaces differ by
251
+ whole words, not typos: the baked library's `list-all-records` against the
252
+ registry's `list-records`.
253
+ """
254
+ wanted = set(action.split("-"))
255
+ scored = [
256
+ (len(wanted & set(c.split("-"))), -len(c), c)
257
+ for c in candidates
258
+ if wanted & set(c.split("-"))
259
+ ]
260
+ return [c for _, _, c in sorted(scored, reverse=True)]
261
+
262
+
263
+ def _unresolved_action_message(connector_key: str, action: str, node_type: str) -> str:
264
+ """What to do when the action id is not a registry key.
265
+
266
+ NOT auto-substituted, deliberately. The baked action id and the registry key
267
+ are different namespaces — a generic op is `list-all-records` in the library
268
+ and materializes per object, while the registry serves `list-records` — and
269
+ guessing between them would emit a node both offline validators accept and
270
+ the runtime dispatches to the wrong operation. So this names the candidates
271
+ and lets the author choose.
272
+
273
+ The message earns its length: in the `generic-dynamic-node` eval the bare
274
+ "check the key/action" version sent an agent to page 18,037 objects through
275
+ `uip is resources list` for six turns instead of retrying with the right id.
276
+ """
277
+ actions = _registry_actions(connector_key)
278
+ lines = [
279
+ f"prepare-connector: `registry get {node_type}` found no such node.",
280
+ "",
281
+ "The baked library's action id is not always the registry's key — a Generic",
282
+ "op is `list-all-records` in the library and materializes per object, while",
283
+ "the registry serves the un-expanded form. Re-run with a registry action id.",
284
+ ]
285
+ if not actions:
286
+ lines += [
287
+ "",
288
+ f"`registry search {connector_key}` returned nothing. If the catalog looks",
289
+ "stale, refresh it first: `uip maestro flow registry pull --force`.",
290
+ ]
291
+ return "\n".join(lines)
292
+ close = _closest_actions(action, actions)
293
+ if close:
294
+ lines += ["", f"Closest to \"{action}\": {', '.join(close[:3])}"]
295
+ lines += ["", f"All actions the registry serves for {connector_key}:", *(f" {a}" for a in actions)]
296
+ return "\n".join(lines)
297
+
298
+
299
+ def main() -> None:
300
+ ap = argparse.ArgumentParser(description="Prepare a connector op's full schema into ./connectors-local")
301
+ ap.add_argument("key", help="connector key, e.g. uipath-atlassian-jira")
302
+ ap.add_argument("action", help="op action id, e.g. create-issue")
303
+ # OPTIONAL. `prepare` already knows the connector; making the author find
304
+ # its connection id by hand is work the command can do from what it has.
305
+ ap.add_argument(
306
+ "--connection-id", default=None,
307
+ help="a live connection id. Omit it and the connection is discovered "
308
+ "from `uip is connections list` — your folder first, the tenant "
309
+ "second — and its id AND folder key are written into bindings.json.")
310
+ ap.add_argument(
311
+ "--connection", default=None, metavar="NAME",
312
+ help="pick the connection by name when several match. Several "
313
+ "connections for one connector are NOT interchangeable, so an "
314
+ "ambiguous match is an error rather than a guess.")
315
+ ap.add_argument(
316
+ "--bindings", default="./bindings.json",
317
+ help="bindings file to add the discovered connection to "
318
+ "(default: ./bindings.json). Additive and idempotent.")
319
+ ap.add_argument(
320
+ "--bind-connection", default=None, metavar="NAME",
321
+ help="symbolic connection name to bind (default: the connector's last "
322
+ "dash-segment, e.g. `slack` for uipath-salesforce-slack)")
323
+ ap.add_argument(
324
+ "--bind-folder", default="shared",
325
+ help="symbolic folder name to bind (default: shared)")
326
+ ap.add_argument(
327
+ "--no-bindings", action="store_true",
328
+ help="discover the connection but do not touch bindings.json")
329
+ objects = ap.add_mutually_exclusive_group()
330
+ objects.add_argument(
331
+ "--object",
332
+ help="expand one connection-discovered object for a Generic action",
333
+ )
334
+ objects.add_argument(
335
+ "--all-objects",
336
+ action="store_true",
337
+ help="expand every connection-discovered object for a Generic action",
338
+ )
339
+ ap.add_argument(
340
+ "-f", "--field", action="append", default=[], metavar="NAME=VALUE",
341
+ help="a PARENT-field value that resolves the schema (repeatable), e.g. "
342
+ "-f fields.project.key=IN -f fields.issuetype.id=10620. Pass every "
343
+ "parent the operation declares: a partial set is rejected. See the "
344
+ "connector guide's parent-field loop.",
345
+ )
346
+ ap.add_argument("--out", default="./connectors-local", help="overlay dir (default: ./connectors-local)")
347
+ ap.add_argument("--source", default=None,
348
+ help="the .flow.ts whose import should be repointed at the "
349
+ "prepared descriptor (default: the one that imports "
350
+ "this connector, when exactly one does)")
351
+ ap.add_argument("--no-source-rewrite", action="store_true",
352
+ help="print the import line instead of editing the source")
353
+ ap.add_argument("--version", default=None, help="pin an op version (default: newest)")
354
+ ap.add_argument("--import", dest="imp", default="../sdk/flow-sdk.js",
355
+ help="module specifier for the emitted .ts (default: ../sdk/flow-sdk.js)")
356
+ ap.add_argument(
357
+ "--resolve", action="append", default=[], metavar="FIELD:BY=VALUE",
358
+ help="resolve a lookup field to the id the connector wants, e.g. "
359
+ "--resolve channel:profile.email=someone@example.com. Repeatable; "
360
+ "order does not matter (a dependsOn prerequisite is resolved first). "
361
+ "Results land in <out>/resolutions.json, which compile reads.")
362
+ ap.add_argument(
363
+ "--refresh-lookups", action="store_true",
364
+ help="re-resolve lookups that are already recorded. Default is to leave "
365
+ "them alone: a resolved id is a snapshot either way, and silently "
366
+ "changing one during an unrelated prepare is worse than a stale one "
367
+ "you can see in a diff.")
368
+ ap.add_argument("--json-only", action="store_true",
369
+ help="write the JSON overlay only — skip the typed .ts descriptor "
370
+ "(the overlay alone already makes compile accept the fields; "
371
+ "the A arm of the typed-vs-JSON study)")
372
+ args = ap.parse_args()
373
+
374
+ parent_fields = _parse_fields(args.field)
375
+ # Set when this run must NOT overwrite an already-prepared schema; see
376
+ # `_keep_prepared_schema`. Reported after the summary line, so the field
377
+ # count it explains has already been printed.
378
+ kept_schema: tuple[int, int] | None = None
379
+
380
+ # Discover the connection when the caller did not name one, and record it in
381
+ # bindings.json so the author never has to look up either key. This is the
382
+ # step that removes 4-7 tool calls per task: listing connections, filtering
383
+ # them, and then hunting Orchestrator for a folder key that was on the
384
+ # connection record all along.
385
+ if not args.connection_id:
386
+ try:
387
+ found = _connections.discover_connection(args.key, args.connection)
388
+ except _connections.ConnectionError_ as error:
389
+ sys.exit(f"prepare-connector: {error}")
390
+ args.connection_id = str(found["Id"])
391
+ print(f" connection: {found.get('Name')} ({args.connection_id})")
392
+ else:
393
+ # An explicit id gets the same gate discovery applies. Without it a
394
+ # `State: Failed` connection went straight to `is resources describe`,
395
+ # which answers Success with NO request fields for a connection the
396
+ # tenant cannot reach — so the overlay was written with zero body
397
+ # inputs, reported as "0 input field(s)" success, and the next compile
398
+ # rejected every real field as unknown (skill-flow-ixp-routing-negative/
399
+ # sf-update: Salesforce update-record on a Failed connection, 0 fields;
400
+ # the Enabled connection carries 13, StageName among them).
401
+ try:
402
+ found = _connections.lookup_connection(args.key, args.connection_id)
403
+ except _connections.ConnectionError_ as error:
404
+ sys.exit(f"prepare-connector: {error}")
405
+ print(f" connection: {found.get('Name')} ({args.connection_id})")
406
+
407
+ # Record the connection in bindings.json on BOTH routes. Until 3.30.5 only
408
+ # discovery wrote it: an explicit `--connection-id` — the one way past two
409
+ # connections that share a name — left the author hand-writing the id and
410
+ # folder key, and the hint below printed `<binding-name>` placeholders that
411
+ # went straight into source and bindings.json (measured on the eval corpus:
412
+ # angle-bracket and all-zero "GUIDs" in 16 archived bindings.json files).
413
+ if not args.no_bindings:
414
+ # Taken before the first write so a later refusal can undo exactly this
415
+ # command's changes, and only them.
416
+ _snapshot_bindings(args)
417
+ symbolic = args.bind_connection or args.key.rsplit("-", 1)[-1]
418
+ try:
419
+ wrote_conn, wrote_folder = _connections.merge_bindings(
420
+ args.bindings, found, symbolic, args.bind_folder)
421
+ except _connections.ConnectionError_ as error:
422
+ sys.exit(f"prepare-connector: {error}")
423
+ args._bound_connection = symbolic
424
+ args._bound_folder = args.bind_folder
425
+ for name, changed in ((symbolic, wrote_conn),
426
+ (args.bind_folder, wrote_folder)):
427
+ if changed:
428
+ print(f" bindings: {name} -> {args.bindings}")
429
+
430
+ gen.set_connection_id(args.connection_id)
431
+ gen.set_parent_fields(parent_fields)
432
+ gen.reset_failed_field_describes()
433
+ node_type = f"uipath.connector.{args.key}.{args.action}"
434
+
435
+ with tempfile.TemporaryDirectory() as tmp:
436
+ cache = os.path.join(tmp, "reg")
437
+ is_cache = os.path.join(tmp, "is")
438
+ os.makedirs(cache)
439
+ os.makedirs(is_cache)
440
+
441
+ path = gen.registry_get(node_type, cache, quiet=True)
442
+ if not path:
443
+ # Not an activity. Connector EVENTS live one namespace over —
444
+ # `uipath.connector.trigger.<key>.<event>` / `.event.<key>.<event>`
445
+ # — and their registry definition only answers with a connection,
446
+ # which this command has by now. Same loop, different twin pair.
447
+ if _prepare_event(args, tmp, cache):
448
+ return
449
+ sys.exit(_unresolved_action_message(args.key, args.action, node_type))
450
+ # The registry's own declaration of parent inputs, read while the file
451
+ # still exists: `path` lives in this temporary directory.
452
+ declared_parents = _declared_parents(path)
453
+ entry = gen.extract_canonical(path)
454
+ if not entry:
455
+ sys.exit(f"prepare-connector: no canonical entry for {node_type} (unexpected registry shape).")
456
+ if args.version:
457
+ entry["version"] = args.version
458
+ if parent_fields:
459
+ request = _object_action_request(path, parent_fields)
460
+ if not request:
461
+ expected = [
462
+ ", ".join(fields)
463
+ for _name, fields in _object_action_specs(path)
464
+ ]
465
+ expected_text = " or ".join(expected) or "<none advertised>"
466
+ sys.exit(
467
+ "prepare-connector: parent-field names must match one "
468
+ "api-type ObjectAction exactly. Given: "
469
+ f"{', '.join(parent_fields)}. Expected: {expected_text}."
470
+ )
471
+ entry["operation"]["customFieldsRequestDetails"] = request
472
+
473
+ is_expandable_generic = (
474
+ entry["operation"].get("activityType") == "Generic"
475
+ and not gen._is_http_passthrough(entry)
476
+ )
477
+ if is_expandable_generic:
478
+ if not args.object and not args.all_objects:
479
+ sys.exit(
480
+ "prepare-connector: this is a Generic action; pass "
481
+ "--object <name> to prepare one object or --all-objects "
482
+ "to prepare the full connected catalog."
483
+ )
484
+ templates = gen._generic_templates_by_op([entry]).get(args.key, {})
485
+ if args.object:
486
+ # The author's name, not necessarily the API's: `Company__C`
487
+ # from a prompt is `Company__c` in the catalog, and equality
488
+ # used to dead-end on exactly that. Match it deterministically
489
+ # BEFORE expansion — expansion re-reads the same file-cached
490
+ # catalog, so the match costs no extra network.
491
+ verb = next(iter(templates)) if len(templates) == 1 else None
492
+ requested = args.object
493
+ matched_row = _match_catalog_object(
494
+ args.key, args.object, verb, is_cache
495
+ )
496
+ if matched_row is not None:
497
+ args.object = matched_row.get("Name") or args.object
498
+ args._object_requested = requested
499
+ args._object_row = matched_row
500
+ args._object_verb = verb
501
+ selected = None if args.all_objects else {args.object}
502
+ prepared_entries = gen.expand_generic_for_connector(
503
+ args.key, templates, is_cache, object_names=selected
504
+ )
505
+ if not prepared_entries:
506
+ operation = entry["operation"].get("name") or args.action
507
+ sys.exit(_missing_generic_schema_message(
508
+ operation,
509
+ None if args.all_objects else args.object,
510
+ ))
511
+ else:
512
+ if args.object or args.all_objects:
513
+ sys.exit(
514
+ "prepare-connector: --object/--all-objects apply only to "
515
+ "Generic connector actions."
516
+ )
517
+ gen.enrich_entry(entry, is_cache) # full schema via --connection-id
518
+ prepared_entries = [entry]
519
+
520
+ tmplib = os.path.join(tmp, "lib")
521
+ gen.write_library(tmplib, prepared_entries, {entry["nodeType"]: path})
522
+ kept_schema = _keep_prepared_schema(
523
+ args, entry["nodeType"], tmplib, parent_fields,
524
+ )
525
+ if kept_schema is None:
526
+ merge_into(tmplib, args.out)
527
+
528
+ # The object decision is the FIRST resolution — recorded before any
529
+ # lookup rows, because it is their prerequisite: the schema the lookups
530
+ # were generated from is the one this match selected.
531
+ if getattr(args, "_object_row", None) is not None:
532
+ try:
533
+ ledger_path = _objects.record_object_resolution(
534
+ args.out, node_type, args._object_requested,
535
+ args._object_row, args._object_verb,
536
+ )
537
+ except _objects.LedgerError as error:
538
+ sys.exit(f"prepare-connector: {error}")
539
+ print(f" object: {args._object_requested} -> {args.object} "
540
+ f"recorded in {ledger_path}")
541
+
542
+ if args.resolve:
543
+ _write_resolutions(args, node_type)
544
+
545
+ _regenerate_descriptors(args)
546
+ n_fields = _count_input_fields(args.out, entry["nodeType"], args.object)
547
+ n_variants = len(prepared_entries)
548
+ ts_note = "JSON overlay only" if args.json_only else f"overlay + {args.key}.ts"
549
+ field_note = _input_field_note(n_fields)
550
+ # The describe is the evidence, not the finished field count. A rejected
551
+ # parent set leaves `enrich_entry` returning the registry-DECLARED fields,
552
+ # so the overlay looks plausible (measured: project-only on Jira
553
+ # create-issue writes 3 declared fields) while the real answer was a
554
+ # Failure. Surface the CLI's own message instead of that quiet success.
555
+ failures = gen.failed_field_describes()
556
+ if args.field and (failures or n_fields == 0):
557
+ sys.exit(_rejected_parent_fields_message(args.field, failures))
558
+ print(
559
+ f"prepare-connector: {node_type} → {args.out}/ "
560
+ f"({ts_note}, {n_variants} variant(s), {field_note})"
561
+ )
562
+ if kept_schema is not None:
563
+ kept, discarded = kept_schema
564
+ print(
565
+ f"prepare-connector: kept the prepared schema for {args.action} "
566
+ f"({kept} input field(s)) — this run passed no -f, so its schema "
567
+ f"action did not run and the unresolved entry ({discarded} field(s)) "
568
+ f"would have replaced it. The resolutions above are recorded either "
569
+ f"way; re-run with -f to refresh the schema itself."
570
+ )
571
+ _print_lookup_vocabulary(args, node_type)
572
+ # Whether this operation has parent fields is answered by what we just
573
+ # prepared, not by a catalog flag: `extract_canonical` does not run the bulk
574
+ # discovery-marking pass, so `requiresConnectionForSchema` is still False
575
+ # here. A reference path carrying a {placeholder} IS the declaration.
576
+ parents, parent_only = _parent_fields(
577
+ args.out, entry["nodeType"], args.object
578
+ )
579
+ if parent_only and args.field:
580
+ # The set was SHAPED right (nothing was rejected) but resolved to
581
+ # nothing new, so at least one value does not exist on this connection.
582
+ sys.exit(
583
+ "prepare-connector: the parent-field values did not resolve — the "
584
+ "prepared schema is still parent-only (" + ", ".join(parents) +
585
+ f"). Given: {', '.join(args.field)}. The names were accepted, so a "
586
+ "VALUE is wrong for this connection: resolve each one against this "
587
+ "connection (ids are parent-scoped — a Jira issue-type id is valid "
588
+ "only inside its project) rather than reusing one from another "
589
+ "project, connection, or task."
590
+ )
591
+ # stdout is block-buffered under a pipe; flush so a `2>&1` reader sees the
592
+ # note after the overlay line it refers to, not before it.
593
+ sys.stdout.flush()
594
+ # Whether the schema action RAN is decided by its inputs, not by its
595
+ # output: every parent it names must have arrived as `-f`. Both earlier
596
+ # tests read the result instead, and between them let Jira's `create-issue`
597
+ # through silently — parent-only PLUS its own `key`, so "every field is a
598
+ # parent" was false and the registry declares no api-type ObjectAction for
599
+ # it either. The overlay then carried 3 of 19 inputs, `prepare` printed a
600
+ # clean "next:", and the author found out when `compile` refused `summary`.
601
+ # Union of both declaration sites so an overlay that omits `schemaAction`
602
+ # still gets the registry's answer.
603
+ action_parents = _schema_action_parents(args.out, entry["nodeType"], args.object)
604
+ action_parents += [
605
+ name for name in declared_parents if name not in action_parents
606
+ ]
607
+ missing = [name for name in action_parents if name not in parent_fields]
608
+ # Silent when the schema was KEPT: the note's premise is that the overlay
609
+ # now carries only the unresolved entry, and it does not — the richer one
610
+ # survived. Telling an author to "materialize the full field set" they are
611
+ # already holding sends them round a loop that changes nothing.
612
+ if missing and kept_schema is None:
613
+ # A schema parent IS a lookup: the id `--resolve` just recorded is the
614
+ # very value `-f` wants. The generic "resolve X and re-run" note made
615
+ # an agent run this prepare twice and hand-copy the id between the two
616
+ # commands (observed: 10732 retyped from the first run's output into
617
+ # the second run's -f). When every parent already has a recorded
618
+ # resolution, print the re-run ready to paste instead.
619
+ resolved_parents = _resolved_parent_values(args.out, node_type, action_parents)
620
+ given = " ".join(
621
+ f"-f {name}={parent_fields[name]}"
622
+ for name in action_parents
623
+ if name in parent_fields
624
+ )
625
+ if set(resolved_parents) == set(action_parents):
626
+ fields_arg = " ".join(
627
+ f"-f {name}={resolved_parents[name]}" for name in action_parents
628
+ )
629
+ print(
630
+ "prepare-connector: note: this operation resolves its fields "
631
+ "from the VALUES of parent inputs, and the resolutions above "
632
+ "ARE those values. Re-run to materialize the full field set:\n"
633
+ f" npx flow-sdk registry prepare {args.key} {args.action} "
634
+ f"{fields_arg}",
635
+ file=sys.stderr,
636
+ )
637
+ else:
638
+ example = " ".join(
639
+ f"-f {name}={parent_fields.get(name, '<value>')}"
640
+ for name in action_parents
641
+ )
642
+ print(
643
+ "prepare-connector: note: this operation resolves its fields "
644
+ "from the VALUES of " + ", ".join(action_parents) +
645
+ " (its schema action), and " +
646
+ ("none were given" if len(missing) == len(action_parents)
647
+ else f"{', '.join(missing)} " +
648
+ ("was" if len(missing) == 1 else "were") + " not" +
649
+ (f" (given: {given})" if given else "")) +
650
+ ", so the overlay above carries only the unresolved schema — a "
651
+ "body field the object declares is not there yet and compile "
652
+ "will refuse it as an unknown input. Re-run passing ALL of them "
653
+ "as -f NAME=VALUE (a partial set is rejected):\n"
654
+ f" npx flow-sdk registry prepare {args.key} {args.action} {example}",
655
+ file=sys.stderr,
656
+ )
657
+ if not args.json_only:
658
+ # The hint printed the placeholders `<binding-name>` /
659
+ # `<folder-binding-name>` even when this run had just WRITTEN both
660
+ # bindings and knew their names. Print what it bound, so the last line
661
+ # of output is a call the reader can paste.
662
+ usage = _descriptor_usage(
663
+ args.out, args.key,
664
+ getattr(args, "_bound_connection", None) or "<binding-name>",
665
+ getattr(args, "_bound_folder", None) or "<folder-binding-name>",
666
+ )
667
+ repointed = _repoint_source(args, args.key)
668
+ if repointed and repointed[0].startswith("repointed "):
669
+ # The edit is DONE, so telling the author to make it is now false.
670
+ # Drop the import line and keep the rest, which still teaches the
671
+ # call shape and the resolved-identity rule.
672
+ usage = [line for line in usage if not line.startswith("import {")]
673
+ for line in repointed:
674
+ print(f"prepare-connector: {line}")
675
+ for line in usage:
676
+ print(f"prepare-connector: next: {line}")
677
+
678
+
679
+ def _print_lookup_vocabulary(args, node_type: str) -> None:
680
+ """Print the lookup table for the operation just prepared.
681
+
682
+ The markdown catalog renders this table for BAKED schemas; a schema that
683
+ only exists because this command ran has no page to carry it, so the
684
+ command itself is where the vocabulary first becomes knowable. Without it
685
+ the author meets a custom object's lookup fields one `check` warning at a
686
+ time instead of all at once, here, beside the import line.
687
+ """
688
+ specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
689
+ if not specs:
690
+ return
691
+ where = f" ({args.object})" if args.object else ""
692
+ print(f"prepare-connector: lookup fields on {args.action}{where} — "
693
+ "these hold ids; author lookup(...) and resolve, don't paste one:")
694
+ shown = list(specs.items())[:12]
695
+ for field, spec in shown:
696
+ by = " | ".join(spec.get("by") or []) or "?"
697
+ target = f"{spec.get('objectName') or spec.get('path') or '?'}.{spec.get('value') or 'id'}"
698
+ print(f" {field} — matches on {by}, sends {target}")
699
+ if len(specs) > len(shown):
700
+ print(f" … and {len(specs) - len(shown)} more")
701
+ field, spec = shown[0]
702
+ by = (spec.get("by") or ["id"])[0]
703
+ obj = f" --object {args.object}" if args.object else ""
704
+ print("prepare-connector: resolve with: "
705
+ f"npx flow-sdk registry prepare {args.key} {args.action}{obj} "
706
+ f"--resolve {field}:{by}=<value>")
707
+
708
+
709
+ def _resolved_parent_values(out_dir: str, node_type: str, parents) -> dict:
710
+ """`{parent: resolved id}` for every parent with a recorded resolution.
711
+
712
+ Read from the ledger just written, so the paste-ready `-f` re-run carries
713
+ the ids THIS invocation resolved — never guessed, never retyped.
714
+ """
715
+ try:
716
+ ledger = _objects.read_ledger(out_dir)
717
+ except _objects.LedgerError:
718
+ return {}
719
+ wanted = set(parents)
720
+ out: dict = {}
721
+ for key, row in (ledger.get("resolutions") or {}).items():
722
+ try:
723
+ key_node, field, _by, _match = json.loads(key)
724
+ except (ValueError, TypeError):
725
+ continue
726
+ if key_node == node_type and field in wanted:
727
+ out[field] = row.get("value")
728
+ return {k: v for k, v in out.items() if v not in (None, "")}
729
+
730
+
731
+ def _match_catalog_object(key: str, wanted: str, verb: str | None, is_cache: str):
732
+ """The catalog row `--object` names, matched by `_objects.match_object`.
733
+
734
+ Returns the row, or None when the catalog itself is unavailable — that
735
+ failure belongs to expansion, whose message already covers it. Every
736
+ refusal past the name rungs (zero matches, substring — unique or
737
+ ambiguous) is retried once against a refreshed catalog before it is
738
+ believed: the `uip is resources list` cache never invalidates itself, so
739
+ an object the tenant gained after the cache was written reads as
740
+ nonexistent (measured: 18 objects stale vs 864 fresh on
741
+ uipath-salesforce-sfdc). The one exception — which refusal is retriable is
742
+ the exception's call, not this loop's — is ambiguity WITHIN a name rung:
743
+ two case-variant names are a fact a refresh will not change.
744
+ """
745
+ rows = gen.is_resources_list(key, is_cache, operation=verb) or []
746
+ if not rows:
747
+ return None
748
+ for attempt in ("cached", "refreshed"):
749
+ try:
750
+ row, note = _objects.match_object(rows, wanted, verb)
751
+ except _objects.ObjectMatchError as error:
752
+ if attempt == "cached" and error.retriable:
753
+ rows = gen.is_resources_list(
754
+ key, is_cache, operation=verb, refresh=True
755
+ ) or rows
756
+ continue
757
+ sys.exit(f"prepare-connector: {error}")
758
+ if note:
759
+ print(f" object: {note}")
760
+ return row
761
+ return None
762
+
763
+
764
+ def _refuse_unless_byoa_connection(args) -> None:
765
+ """Refuse a standard connection on an event object that requires BYOA.
766
+
767
+ V1 measured the failure this prevents: the trigger deploys and then faults
768
+ at runtime with a webhook error naming nothing about connections. The
769
+ requirement is knowable now, so it is refused now, with the connections
770
+ that WOULD work.
771
+
772
+ A refusal must leave nothing behind. Stage 0 has already written this
773
+ connection into `bindings.json` by the time the event's own metadata says
774
+ it is the wrong KIND of connection — the BYOA fact needs a connection to
775
+ discover — so the file is restored to exactly what it was before the
776
+ refusal is raised. Otherwise the overlay keeps pointing at a connection
777
+ this command just forbade.
778
+ """
779
+ try:
780
+ rows = _connections.byoa_connections(args.key)
781
+ except _connections.ConnectionError_ as error:
782
+ # A listing that FAILED is not a tenant with no BYOA connections; the
783
+ # remedies are opposite and confusing them sends the reader to create
784
+ # a connection they may already have.
785
+ _restore_bindings(args)
786
+ sys.exit(
787
+ f"prepare-connector: this event object requires a BYOA "
788
+ f"(bring-your-own-app) connection, and the BYOA listing needed to "
789
+ f"check {args.connection_id} against it failed: {error}"
790
+ )
791
+ if args.connection_id in {str(row.get("Id")) for row in rows}:
792
+ return
793
+ _restore_bindings(args)
794
+ sys.exit(
795
+ f"prepare-connector: this event object requires a BYOA "
796
+ f"(bring-your-own-app) connection, and {args.connection_id} is not "
797
+ f"one. A standard connection deploys and then faults at runtime with "
798
+ f"an unrelated-looking webhook error. BYOA {args.key} connections:\n"
799
+ f"{_connections.byoa_listing(rows, args.key)}"
800
+ )
801
+
802
+
803
+ def _event_operation(operation: dict) -> str:
804
+ """The operation key every trigger API takes.
805
+
806
+ NOT `operation.name`: that is the object's CRUD verb (`Create` on outlook
807
+ `email-received`), while the event's own operation is `EMAIL_RECEIVED`,
808
+ which `extract_canonical` stamps as `eventOperation` from the manifest's
809
+ `model.context`. `name` happens to fall back to the same context value
810
+ when the manifest omits `connectorMethodInfo.operation`, so reading it
811
+ works or silently queries the WRONG operation depending on which fallback
812
+ rung a given connector's manifest exercises — and both generation passes
813
+ already key on `eventOperation`.
814
+ """
815
+ return str(operation.get("eventOperation") or operation.get("name") or "")
816
+
817
+
818
+ def _snapshot_bindings(args) -> None:
819
+ """Remember `bindings.json` byte-for-byte, before stage 0 touches it."""
820
+ path = getattr(args, "bindings", None)
821
+ if not path:
822
+ return
823
+ try:
824
+ with open(path, "rb") as handle:
825
+ args._bindings_before = handle.read()
826
+ except OSError:
827
+ args._bindings_before = None # the file did not exist
828
+
829
+
830
+ def _restore_bindings(args) -> None:
831
+ """Put `bindings.json` back exactly as `_snapshot_bindings` found it."""
832
+ path = getattr(args, "bindings", None)
833
+ if not path or not hasattr(args, "_bindings_before"):
834
+ return
835
+ before = args._bindings_before
836
+ try:
837
+ if before is None:
838
+ if os.path.exists(path):
839
+ os.remove(path)
840
+ else:
841
+ with open(path, "wb") as handle:
842
+ handle.write(before)
843
+ except OSError as error:
844
+ print(f" WARN: could not restore {path} after refusing the "
845
+ f"connection: {error}", file=sys.stderr)
846
+
847
+
848
+ def _prepare_event(args, tmp: str, cache: str) -> bool:
849
+ """Prepare a connector EVENT — the trigger/wait twin of an activity.
850
+
851
+ Returns False when `<key> <action>` is not an event either, so the caller
852
+ can raise the ordinary unknown-action error. Everything else is handled
853
+ here: the overlay entry is stored under the WAIT form
854
+ (`uipath.connector.event.<key>.<action>`) because the registry only
855
+ enriches the START form and the library derives the start form from its
856
+ wait sibling (`deriveTriggerForm`) — one stored variant serves both node
857
+ roles.
858
+ """
859
+ trigger_nt = f"uipath.connector.trigger.{args.key}.{args.action}"
860
+ event_nt = f"uipath.connector.event.{args.key}.{args.action}"
861
+ path = gen.registry_get(trigger_nt, cache)
862
+ if not path:
863
+ return False
864
+ entry = gen.extract_canonical(path)
865
+ if not entry:
866
+ return False
867
+ if args.field:
868
+ sys.exit("prepare-connector: -f parent fields apply to activities; "
869
+ "a connector event has none.")
870
+ if args.all_objects:
871
+ sys.exit("prepare-connector: --all-objects applies to Generic "
872
+ "activities; prepare a generic EVENT for one --object.")
873
+ with open(path, encoding="utf-8") as handle:
874
+ raw = json.load(handle)
875
+ node = (raw.get("Data") or {}).get("Node") or raw.get("Node") or {}
876
+ op = entry["operation"]
877
+ operation = _event_operation(op)
878
+ generic = op.get("activityType") == "GenericTrigger"
879
+
880
+ # The objects listing is the generic catalog AND the per-object fact sheet
881
+ # (BYOA, webhook visibility, bucket-scoped Parameters). For a curated
882
+ # event it is advisory — the definition already carries the sections — so
883
+ # a listing failure degrades to a warning there and is fatal only where
884
+ # it is the only source of truth.
885
+ try:
886
+ rows = _events.triggers_objects(args.key, operation, args.connection_id)
887
+ except _events.EventDiscoveryError as error:
888
+ if generic:
889
+ sys.exit(f"prepare-connector: {error}")
890
+ rows = []
891
+ print(f" WARN: {error}", file=sys.stderr)
892
+
893
+ describe = None
894
+ object_row = None
895
+ requested_object = None
896
+ if generic:
897
+ if not args.object:
898
+ sample = ", ".join(
899
+ str(row.get("Name")) for row in rows[:8] if row.get("Name"))
900
+ sys.exit(
901
+ f"prepare-connector: {args.action} is a Generic event — it "
902
+ f"fires for ONE object of the connection. Pass --object "
903
+ f"<name>. This connection's objects include: {sample}"
904
+ + (" …" if len(rows) > 8 else "")
905
+ )
906
+ # The author's name, not necessarily the API's — same ladder, same
907
+ # refusal semantics as a Generic activity. The listing is live (no
908
+ # CLI-side cache), so there is nothing to refresh on a miss.
909
+ try:
910
+ object_row, note = _objects.match_object(rows, args.object, operation)
911
+ except _objects.ObjectMatchError as error:
912
+ sys.exit(f"prepare-connector: {error}")
913
+ if note:
914
+ print(f" object: {note}")
915
+ requested_object = args.object
916
+ args.object = object_row.get("Name") or args.object
917
+ try:
918
+ describe = _events.triggers_describe(
919
+ args.key, operation, args.object, args.connection_id)
920
+ except _events.EventDiscoveryError as error:
921
+ sys.exit(f"prepare-connector: {error}")
922
+ op["objectName"] = args.object
923
+ op["objectDisplayName"] = str(object_row.get("DisplayName") or "")
924
+ else:
925
+ if args.object:
926
+ sys.exit(
927
+ f"prepare-connector: --object applies to Generic events; "
928
+ f"{args.action}'s object is built in "
929
+ f"({op.get('objectName') or 'none'})."
930
+ )
931
+ baked = str(op.get("objectName") or "")
932
+ object_row = next(
933
+ (row for row in rows if str(row.get("Name")) == baked), None)
934
+ # `triggers describe` is the AUTHORITY for a curated event too, not
935
+ # just a generic one. The registry definition mirrors it and the
936
+ # mirror is lossy: measured on outlook `email-received`, describe
937
+ # returns 15 filter fields and the registry 14 — `isRead` is only in
938
+ # describe, and both agree on all 73 output fields. Reading the mirror
939
+ # would leave `check` REFUSING a filter on `isRead` as an unknown
940
+ # field, which is the one diagnostic here that is an error. Fall back
941
+ # to the definition's own sections when describe cannot answer (a
942
+ # baked object name the trigger catalog does not list) rather than
943
+ # failing the prepare — the mirror is still a usable vocabulary.
944
+ if baked:
945
+ try:
946
+ describe = _events.triggers_describe(
947
+ args.key, operation, baked, args.connection_id)
948
+ except _events.EventDiscoveryError as error:
949
+ print(f" WARN: {error}\n"
950
+ f" falling back to the registry definition's own "
951
+ f"schema sections for {baked}", file=sys.stderr)
952
+
953
+ facts = _events.event_facts(node, object_row)
954
+ if facts["byoaConnection"]:
955
+ _refuse_unless_byoa_connection(args)
956
+
957
+ # One stored shape: the registry definition with the describe sections
958
+ # made true for this object (generic), or with the object row's
959
+ # Parameters unioned in (curated). `Library.eventParameterFields` reads
960
+ # `v1def.eventParameters.fields`, so the overlay v1def IS the store.
961
+ object_parameters = (object_row or {}).get("Parameters")
962
+ if describe is not None:
963
+ node = _events.merge_describe_into_node(node, describe, object_parameters)
964
+ else:
965
+ node = dict(node)
966
+ node["eventParameters"] = {"fields": _events.union_event_parameters(
967
+ (node.get("eventParameters") or {}).get("fields"),
968
+ object_parameters,
969
+ )}
970
+ if (node.get("outputResponseDefinition") or {}).get("fields"):
971
+ entry["outputSchema"] = {
972
+ "fields": node["outputResponseDefinition"]["fields"]}
973
+ node["nodeType"] = event_nt
974
+ entry["nodeType"] = event_nt
975
+
976
+ merged_path = os.path.join(tmp, "event-def.json")
977
+ with open(merged_path, "w", encoding="utf-8") as handle:
978
+ json.dump({"Data": {"Node": node}}, handle)
979
+
980
+ tmplib = os.path.join(tmp, "eventlib")
981
+ gen.write_library(tmplib, [entry], {event_nt: merged_path})
982
+ merge_into(tmplib, args.out)
983
+
984
+ if generic:
985
+ try:
986
+ ledger_path = _objects.record_object_resolution(
987
+ args.out, event_nt, requested_object, object_row, operation)
988
+ except _objects.LedgerError as error:
989
+ sys.exit(f"prepare-connector: {error}")
990
+ print(f" object: {requested_object} -> {args.object} "
991
+ f"recorded in {ledger_path}")
992
+
993
+ if args.resolve:
994
+ _write_resolutions(args, event_nt)
995
+
996
+ _regenerate_descriptors(args)
997
+
998
+ where_fields = (node.get("eventParameters") or {}).get("fields") or []
999
+ filter_fields = (node.get("filterFields") or {}).get("fields") or []
1000
+ mode = facts["eventMode"]
1001
+ print(f"prepare-connector: {event_nt} → {args.out}/ (event overlay, "
1002
+ f"{operation}, {mode}, {len(where_fields)} where-parameter(s), "
1003
+ f"{len(filter_fields)} filter field(s))")
1004
+ sys.stdout.flush()
1005
+ _print_lookup_vocabulary(args, event_nt)
1006
+ if filter_fields:
1007
+ names = ", ".join(str(f.get("name")) for f in filter_fields[:8])
1008
+ print(f"prepare-connector: filterable fields ({len(filter_fields)}): "
1009
+ f"{names}" + (" …" if len(filter_fields) > 8 else ""))
1010
+ if mode == "webhooks":
1011
+ # Registration is an act on the external service — the one step this
1012
+ # loop cannot make deterministic. Hand the author the connector's own
1013
+ # words instead of leaving `uip is webhooks config` to be discovered.
1014
+ print("prepare-connector: note: this event is WEBHOOK mode — it "
1015
+ "cannot be debugged locally (deploy to test), and the webhook "
1016
+ "URL must be registered on the external service"
1017
+ + (":" if facts["textBlocks"] else
1018
+ " (retrieve it with `uip is webhooks config`)."),
1019
+ file=sys.stderr)
1020
+ for block in facts["textBlocks"]:
1021
+ print(f" {block}", file=sys.stderr)
1022
+ connection_name = getattr(args, "_bound_connection", None) or "<connection>"
1023
+ folder_name = getattr(args, "_bound_folder", None) or "shared"
1024
+ subscription = (
1025
+ f"{{ connector: '{args.key}', event: '{args.action}'"
1026
+ + (f", object: '{args.object}'" if generic else "")
1027
+ + f", connection: '{connection_name}', folder: '{folder_name}' }}"
1028
+ )
1029
+ print(f"prepare-connector: next: .trigger(onEvent({subscription})) to "
1030
+ f"start the flow on this event — or .step('…', "
1031
+ f"waitForEvent({subscription})) to pause mid-flow for it")
1032
+ return True
1033
+
1034
+
1035
+ def _overlay_has_generic_expanded(out: str) -> bool:
1036
+ """Does the overlay hold ANY `GenericExpanded` entry, from any prepare?"""
1037
+ try:
1038
+ with open(os.path.join(out, "index.json"), encoding="utf-8") as handle:
1039
+ entries = json.load(handle).get("entries") or []
1040
+ except (OSError, ValueError):
1041
+ return False
1042
+ return any(e.get("activityType") == "GenericExpanded" for e in entries)
1043
+
1044
+
1045
+ def _regenerate_descriptors(args) -> None:
1046
+ """Rewrite the overlay's typed descriptors from the overlay's own JSON.
1047
+
1048
+ Both prepare paths end here. The EVENT path used not to: it wrote the JSON
1049
+ overlay and returned, so a project whose only prepare was an event got no
1050
+ `.ts` at all, and one that had prepared an action earlier kept that older
1051
+ run's module — generated before the event's parameters were in the overlay,
1052
+ so `where` was `Record<string, string>` and the trigger carried no
1053
+ `lookups`. That is the module `prepare` tells the author to import, and
1054
+ `--refresh-lookups` could not repair it, because nothing on the event path
1055
+ regenerated TypeScript at all.
1056
+
1057
+ `--include-generic-expanded` is decided from the WHOLE overlay index rather
1058
+ than the entries of this run: the generator rewrites every module under
1059
+ `--output`, so reading one run would drop the descriptors an earlier
1060
+ `GenericExpanded` prepare had written.
1061
+ """
1062
+ if args.json_only:
1063
+ return
1064
+ command = [
1065
+ sys.executable, str(HERE / "generate_connectors_ts.py"),
1066
+ "--library", args.out, "--output", args.out, "--import", args.imp,
1067
+ ]
1068
+ if _overlay_has_generic_expanded(args.out):
1069
+ command.append("--include-generic-expanded")
1070
+ subprocess.run(command, check=True)
1071
+
1072
+
1073
+ def _write_resolutions(args, node_type: str) -> None:
1074
+ """Resolve every `--resolve` and merge the answers into the overlay.
1075
+
1076
+ A SEPARATE FILE from the schema overlay, deliberately. A prepared schema is
1077
+ a fact about the connector; a resolution is a fact about one tenant's data,
1078
+ so it ages differently and most consumers will want to gitignore it. Keeping
1079
+ them apart is also what lets `--refresh-lookups` mean something later.
1080
+
1081
+ Existing entries STAND rather than being re-resolved. A resolved id is a
1082
+ snapshot either way; re-resolving on every call would make an unrelated
1083
+ `prepare` silently change an artifact, which is the opposite of what the
1084
+ file is for. Re-resolving is a deliberate act with a visible diff.
1085
+ """
1086
+ specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
1087
+ requested = [_resolve.parse_resolve_flag(raw) for raw in args.resolve]
1088
+
1089
+ unknown = [f for f, _b, _v in requested if f not in specs]
1090
+ if unknown:
1091
+ known = ", ".join(sorted(specs)) or "(none)"
1092
+ sys.exit(
1093
+ f"prepare-connector: no lookup is declared for "
1094
+ f"{', '.join(repr(f) for f in unknown)} on {node_type}. "
1095
+ f"Fields with a lookup: {known}."
1096
+ )
1097
+
1098
+ ordered = _resolve.order_resolutions(requested, specs)
1099
+ path = os.path.join(args.out, "resolutions.json")
1100
+ # Read and rewrite the WHOLE ledger, not just `resolutions`: the file also
1101
+ # carries the `objects` section (the object decision recorded above), and
1102
+ # rewriting only one section used to silently drop the other. A file that
1103
+ # will not parse is REFUSED rather than reset — same rule as bindings.json.
1104
+ try:
1105
+ ledger = _objects.read_ledger(args.out)
1106
+ except _objects.LedgerError as error:
1107
+ sys.exit(f"prepare-connector: {error}")
1108
+ existing = ledger.get("resolutions") or {}
1109
+
1110
+ resolved_values: dict[str, str] = {}
1111
+ for field, by, value in ordered:
1112
+ # COMPACT SEPARATORS, to match JavaScript exactly. `compile` computes
1113
+ # this key with `JSON.stringify`, which emits no space after a comma;
1114
+ # Python's default emits `", "`. The two encodings of the same tuple are
1115
+ # different strings, so every resolution `prepare` wrote was invisible to
1116
+ # the compile that had just asked for it — the command did the work,
1117
+ # recorded it, and then refused to use it.
1118
+ key = json.dumps([node_type, field, by, value], separators=(",", ":"))
1119
+ if key in existing and not args.refresh_lookups:
1120
+ resolved_values[field] = existing[key]["value"]
1121
+ print(f" {field}: {by}={value} -> {existing[key]['value']} (already resolved)")
1122
+ continue
1123
+ try:
1124
+ got = _resolve.resolve_one(
1125
+ args.key, specs[field], field, by, value,
1126
+ args.connection_id, resolved_values,
1127
+ )
1128
+ except _resolve.ResolutionError as error:
1129
+ sys.exit(f"prepare-connector: {error}")
1130
+ resolved_values[field] = got
1131
+ existing[key] = {
1132
+ "nodeType": node_type, "field": field, "by": by,
1133
+ "match": value, "value": got,
1134
+ }
1135
+ print(f" {field}: {by}={value} -> {got}")
1136
+
1137
+ os.makedirs(args.out, exist_ok=True)
1138
+ ledger["schemaVersion"] = "1"
1139
+ ledger["resolutions"] = existing
1140
+ with open(path, "w", encoding="utf-8") as handle:
1141
+ json.dump(ledger, handle, indent=2, sort_keys=True)
1142
+ handle.write("\n")
1143
+ print(f" wrote {len(existing)} resolution(s) to {path}")
1144
+
1145
+
1146
+ def _lookup_specs_from_overlay(
1147
+ out_dir: str, node_type: str, object_name: str | None = None,
1148
+ ) -> dict:
1149
+ """The prepared operation's lookup specs, from the overlay just written.
1150
+
1151
+ Read back from the overlay rather than recomputed, so a resolution is made
1152
+ against the SAME schema the descriptors were generated from — including any
1153
+ connection-resolved field that only exists because this `prepare` ran.
1154
+ """
1155
+ index_path = os.path.join(out_dir, "index.json")
1156
+ if not os.path.exists(index_path):
1157
+ return {}
1158
+ with open(index_path, encoding="utf-8") as handle:
1159
+ entries = json.load(handle).get("entries", [])
1160
+ invariant = load_invariant_collections(out_dir)
1161
+ for entry in entries:
1162
+ if entry.get("nodeType") != node_type:
1163
+ continue
1164
+ # Generic object variants share a nodeType; without the object filter
1165
+ # the FIRST variant answered for all of them, so a `--resolve` against
1166
+ # the object just prepared read another object's lookup table.
1167
+ if object_name and entry.get("objectName") not in ("", None, object_name):
1168
+ continue
1169
+ entry_path = os.path.join(out_dir, entry["path"])
1170
+ v1_path = entry_path[:-5] + ".v1def.json"
1171
+ if not os.path.exists(entry_path):
1172
+ continue
1173
+ with open(entry_path, encoding="utf-8") as handle:
1174
+ canonical = json.load(handle)
1175
+ v1def = {}
1176
+ if os.path.exists(v1_path):
1177
+ with open(v1_path, encoding="utf-8") as handle:
1178
+ raw = json.load(handle)
1179
+ v1def = raw[0] if isinstance(raw, list) else raw
1180
+ # An EVENT's resolvable fields are its subscription parameters, and
1181
+ # `v1def.eventParameters.fields` is the ONE authority for those — the
1182
+ # same list the SDK, `check` and the descriptor generator read.
1183
+ #
1184
+ # This used to pick the source by testing whether `inputSchema` was
1185
+ # empty, which was right only because the prepare path happens to
1186
+ # leave `inputSchema` as the manifest wrote it while generation
1187
+ # overwrites it. Either path changing flips the heuristic and the
1188
+ # specs come from the manifest's message-BODY fields instead — the
1189
+ # exact wrong list. The node kind is the fact being tested, so test
1190
+ # it.
1191
+ if node_type.startswith(("uipath.connector.event.",
1192
+ "uipath.connector.trigger.")):
1193
+ fields_source = {"inputSchema": {
1194
+ "fields": (v1def.get("eventParameters") or {}).get("fields") or []
1195
+ }}
1196
+ else:
1197
+ fields_source = canonical
1198
+ specs = gen_ts.lookup_specs(fields_source, v1def, invariant)
1199
+ # `filterPattern` is not on the descriptor spec (it is a retrieval
1200
+ # detail, not an authoring one) but the resolver needs it.
1201
+ catalog = {
1202
+ f.get("name"): f["reference"]
1203
+ for f in ((fields_source.get("inputSchema") or {}).get("fields") or [])
1204
+ if isinstance(f.get("reference"), dict)
1205
+ }
1206
+ for field, spec in specs.items():
1207
+ reference = catalog.get(field) or {}
1208
+ if reference.get("filterPattern"):
1209
+ spec["filterPattern"] = reference["filterPattern"]
1210
+ return specs
1211
+ return {}
1212
+
1213
+
1214
+ # `import … from "<specifier>"` — the specifier is group 3, quotes preserved.
1215
+ # Deliberately anchored to a real import STATEMENT: the instruction this
1216
+ # replaces became `sed 's|./connectors/|./connectors-local/|'` in practice,
1217
+ # which also rewrites the same text inside comments and inside the imports of
1218
+ # connectors this run did NOT prepare (#704).
1219
+ _IMPORT_RE = re.compile(
1220
+ r"""(^\s*import\s[^;'"]*?\sfrom\s*)(['"])([^'"]+)(\2)""",
1221
+ re.MULTILINE,
1222
+ )
1223
+
1224
+ # Where a `.flow.ts` will not be: generated output and installed packages.
1225
+ _SOURCE_SKIP_DIRS = {"node_modules", "connectors", "connectors-local", ".git"}
1226
+
1227
+
1228
+ def _resolves_to(specifier: str, source: Path, target: Path) -> bool:
1229
+ """Does `specifier`, read from `source`, name the file `target`?
1230
+
1231
+ Compared as RESOLVED paths, not as text, so `./connectors/x.ts` from the
1232
+ project root and `../connectors/x.ts` from a nested source both match the
1233
+ one file — and a same-named connector under a different root does not.
1234
+ """
1235
+ if not specifier.startswith("."):
1236
+ return False # a bare package, never our overlay
1237
+ try:
1238
+ candidate = (source.parent / specifier).resolve()
1239
+ except (OSError, ValueError):
1240
+ return False
1241
+ return candidate == target
1242
+
1243
+
1244
+ def _sources_importing(baked: Path, root: Path) -> list[Path]:
1245
+ """Every `*.flow.ts` under `root` whose import resolves to `baked`.
1246
+
1247
+ Selection is by CONTENT rather than "the single flow in this directory":
1248
+ a workspace with three flows, one of which uses this connector, has one
1249
+ correct answer and this finds it.
1250
+ """
1251
+ found: list[Path] = []
1252
+ for path in sorted(root.rglob("*.flow.ts")):
1253
+ if _SOURCE_SKIP_DIRS & set(path.relative_to(root).parts[:-1]):
1254
+ continue
1255
+ try:
1256
+ text = path.read_text(encoding="utf-8")
1257
+ except (OSError, UnicodeDecodeError):
1258
+ continue
1259
+ if any(_resolves_to(m.group(3), path, baked) for m in _IMPORT_RE.finditer(text)):
1260
+ found.append(path)
1261
+ return found
1262
+
1263
+
1264
+ def _repoint_import(source: Path, baked: Path, prepared: Path) -> str | None:
1265
+ """Point `source`'s import at the prepared descriptor. The new specifier, or None.
1266
+
1267
+ Only the quoted path changes. The rest of the statement — the named
1268
+ bindings, an `as` alias, the quote style — is carried through untouched,
1269
+ which is the whole difference between this and the find-and-replace it
1270
+ replaces.
1271
+ """
1272
+ try:
1273
+ text = source.read_text(encoding="utf-8")
1274
+ except (OSError, UnicodeDecodeError):
1275
+ return None
1276
+ specifier = os.path.relpath(prepared, source.parent).replace(os.sep, "/")
1277
+ if not specifier.startswith("."):
1278
+ specifier = f"./{specifier}"
1279
+ changed = False
1280
+
1281
+ def swap(match: "re.Match[str]") -> str:
1282
+ nonlocal changed
1283
+ if not _resolves_to(match.group(3), source, baked):
1284
+ return match.group(0)
1285
+ changed = True
1286
+ return f"{match.group(1)}{match.group(2)}{specifier}{match.group(4)}"
1287
+
1288
+ rewritten = _IMPORT_RE.sub(swap, text)
1289
+ if not changed or rewritten == text:
1290
+ return None
1291
+ source.write_text(rewritten, encoding="utf-8")
1292
+ return specifier
1293
+
1294
+
1295
+ def _repoint_source(args, connector_key: str) -> list[str]:
1296
+ """Repoint the author's import, or explain why it was left alone.
1297
+
1298
+ `prepare` already writes `bindings.json`, the overlay descriptor and
1299
+ `resolutions.json`; the one thing it did NOT do was update the import
1300
+ naming the descriptor it had just replaced. That left a mechanical edit —
1301
+ one specifier, one file, exactly one right answer — to the caller, who has
1302
+ strictly less information than this command does. Measured cost: an agent
1303
+ reaches for `sed`, which repoints connectors that were never prepared and
1304
+ breaks the flow with `Cannot find module`.
1305
+
1306
+ Ambiguity keeps today's behaviour. Zero candidates or several, and nothing
1307
+ is written — the printed import line still tells the author what to do, so
1308
+ the fallback is exactly the status quo and never worse.
1309
+ """
1310
+ prepared = (Path(args.out) / f"{connector_key}.ts").resolve()
1311
+ if getattr(args, "no_source_rewrite", False) or not prepared.is_file():
1312
+ return []
1313
+ baked = (Path.cwd() / "connectors" / f"{connector_key}.ts").resolve()
1314
+
1315
+ if getattr(args, "source", None):
1316
+ candidates = [Path(args.source).resolve()]
1317
+ if not candidates[0].is_file():
1318
+ return [f"--source {args.source} does not name a file; "
1319
+ f"left the import alone"]
1320
+ else:
1321
+ candidates = _sources_importing(baked, Path.cwd())
1322
+
1323
+ if not candidates:
1324
+ return []
1325
+ if len(candidates) > 1:
1326
+ names = ", ".join(
1327
+ c.relative_to(Path.cwd()).as_posix() for c in candidates)
1328
+ return [f"{len(candidates)} sources import this connector ({names}); "
1329
+ f"name one with --source to have it repointed"]
1330
+
1331
+ source = candidates[0]
1332
+ specifier = _repoint_import(source, baked, prepared)
1333
+ if specifier is None:
1334
+ return []
1335
+ return [f"repointed {source.relative_to(Path.cwd()).as_posix()} "
1336
+ f"-> {specifier}"]
1337
+
1338
+
1339
+ def _descriptor_usage(out_dir: str, connector_key: str,
1340
+ bound_connection: str = "<binding-name>",
1341
+ bound_folder: str = "<folder-binding-name>") -> list[str]:
1342
+ """Teach the author how to consume the connection-resolved descriptor.
1343
+
1344
+ The live registry may use a different action id than the baked catalog. The
1345
+ generated descriptor is the authority for that resolved identity, so the
1346
+ success message must carry the author across that boundary instead of
1347
+ leaving them to reverse-engineer index.json.
1348
+ """
1349
+ source = Path(out_dir) / f"{connector_key}.ts"
1350
+ try:
1351
+ generated = source.read_text()
1352
+ exports = re.findall(
1353
+ r"^export const ([A-Za-z_$][A-Za-z0-9_$]*) = "
1354
+ r"descriptor<([A-Za-z_$][A-Za-z0-9_$]*),",
1355
+ generated,
1356
+ flags=re.MULTILINE,
1357
+ )
1358
+ except OSError:
1359
+ generated = ""
1360
+ exports = []
1361
+
1362
+ import_path = os.path.relpath(source, Path.cwd()).replace(os.sep, "/")
1363
+ if not import_path.startswith("."):
1364
+ import_path = f"./{import_path}"
1365
+ if len(exports) == 1:
1366
+ descriptor_name, inputs_interface = exports[0]
1367
+ required_fields = _required_interface_fields(
1368
+ generated, inputs_interface
1369
+ )
1370
+ if required_fields is None:
1371
+ return [
1372
+ f'open "{source.as_posix()}" and import {descriptor_name}',
1373
+ "read its generated inputs interface before calling connector; "
1374
+ "the required-input skeleton is unavailable",
1375
+ ]
1376
+ inputs = "{}"
1377
+ if required_fields:
1378
+ placeholders = ", ".join(
1379
+ f"{name}: /* {field_type}, required */"
1380
+ for name, field_type in required_fields
1381
+ )
1382
+ inputs = f"{{ {placeholders} }}"
1383
+ return [
1384
+ f'import {{ {descriptor_name} }} from "{import_path}";',
1385
+ f'connector({descriptor_name}, {inputs}, '
1386
+ f'{{ connection: "{bound_connection}", folder: "{bound_folder}" }})',
1387
+ "use this descriptor as generated: it carries the resolved live "
1388
+ "operation and object identity even when the baked action id differs",
1389
+ ]
1390
+
1391
+ return [
1392
+ f'open "{source.as_posix()}" and import the descriptor for the object you need',
1393
+ "use that descriptor with connector(descriptor, inputs, opts); do not "
1394
+ "reconstruct its live operation/object identity from the baked catalog",
1395
+ ]
1396
+
1397
+
1398
+ def _required_interface_fields(
1399
+ generated: str, interface_name: str
1400
+ ) -> list[tuple[str, str]] | None:
1401
+ match = re.search(
1402
+ rf"^export interface {re.escape(interface_name)} \{{(?P<body>.*?)^\}}",
1403
+ generated,
1404
+ flags=re.MULTILINE | re.DOTALL,
1405
+ )
1406
+ if not match:
1407
+ return None
1408
+
1409
+ required: list[tuple[str, str]] = []
1410
+ property_pattern = re.compile(
1411
+ r'^ (?P<name>[A-Za-z_$][A-Za-z0-9_$]*|"(?:\\.|[^"\\])*")'
1412
+ r'(?P<optional>\?)?: (?P<type>.+);$'
1413
+ )
1414
+ for line in match.group("body").splitlines():
1415
+ stripped = line.strip()
1416
+ if not stripped or stripped.startswith(("/**", "*", "*/")):
1417
+ continue
1418
+ field = property_pattern.match(line)
1419
+ if not field:
1420
+ return None
1421
+ if not field.group("optional"):
1422
+ required.append((field.group("name"), _unwrap_value(field.group("type"))))
1423
+ return required
1424
+
1425
+
1426
+ def _unwrap_value(ts_type: str) -> str:
1427
+ """`ConnectorValue<string>` -> `string`, for the handoff skeleton.
1428
+
1429
+ The generated property type says what the field ACCEPTS — the literal or an
1430
+ expression — which is the right thing for the compiler and the wrong thing
1431
+ for a one-line "what goes here" hint. An author reading
1432
+ `/* ConnectorValue<string>, required */` learns less than one reading
1433
+ `/* string, required */`, and the wrapper is not something they type.
1434
+ """
1435
+ match = re.fullmatch(r"Connector(?:Lookup)?Value<(.+)>", ts_type.strip())
1436
+ return match.group(1) if match else ts_type
1437
+
1438
+
1439
+ def _count_input_fields(
1440
+ out_dir: str, node_type: str, object_name: str | None = None
1441
+ ) -> int | None:
1442
+ """Input-field count across matching overlay variants."""
1443
+ try:
1444
+ with open(os.path.join(out_dir, "index.json")) as index_file:
1445
+ idx = json.load(index_file)
1446
+ entries = idx["entries"]
1447
+ if not isinstance(entries, list):
1448
+ return None
1449
+ total = 0
1450
+ matched = False
1451
+ for index_entry in entries:
1452
+ if index_entry["nodeType"] != node_type:
1453
+ continue
1454
+ if object_name is not None and index_entry.get("objectName") != object_name:
1455
+ continue
1456
+ matched = True
1457
+ with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1458
+ descriptor = json.load(entry_file)
1459
+ if not isinstance(descriptor, dict):
1460
+ return None
1461
+ input_schema = descriptor.get("inputSchema")
1462
+ if not isinstance(input_schema, dict):
1463
+ return None
1464
+ fields = input_schema.get("fields")
1465
+ if not isinstance(fields, list):
1466
+ return None
1467
+ total += len(fields)
1468
+ return total if matched else None
1469
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
1470
+ return None
1471
+
1472
+
1473
+ def _declared_parents(registry_path: str) -> list[str]:
1474
+ """Parent-input names the registry declares through api-type ObjectActions.
1475
+
1476
+ The second way an operation says "my fields depend on a value": not a
1477
+ `{placeholder}` in a reference path (Jira), but a design-time action whose
1478
+ rules name the inputs it reads (Data Fabric `create-entity-record` declares
1479
+ `GenerateSchema` over `entityName`). `_object_action_specs` already reads
1480
+ them for the replay request; this is the same list, flattened and in
1481
+ declaration order, for the no-`-f` note.
1482
+ """
1483
+ names: list[str] = []
1484
+ for _action, refs in _object_action_specs(registry_path):
1485
+ for ref in refs:
1486
+ if ref not in names:
1487
+ names.append(ref)
1488
+ return names
1489
+
1490
+
1491
+ def _schema_action_parents(
1492
+ out_dir: str, node_type: str, object_name: str | None = None
1493
+ ) -> list[str]:
1494
+ """Parent inputs the PREPARED operation's own schema action names.
1495
+
1496
+ The third and most direct way an operation says "my fields depend on a
1497
+ value", and the only one that is a PRECONDITION rather than a symptom: the
1498
+ descriptor just written carries `operation.schemaAction.parents`, so with
1499
+ any of those missing from `-f` the action cannot have run, whatever the
1500
+ describe returned. `_parent_fields` and `_declared_parents` both read the
1501
+ result or the registry instead, and each has a shape it cannot see:
1502
+
1503
+ - `_parent_fields` asks "is EVERY prepared field a parent?". Jira's
1504
+ `create-issue` comes back parent-only PLUS `key` (its own issue key), so
1505
+ that test reads an unresolved schema as resolved and says nothing while
1506
+ `compile` is about to refuse `summary` as an unknown input — measured on
1507
+ a real run that then fell back to the generic `insert-record`.
1508
+ - `_declared_parents` reads the registry's api-type ObjectActions, which is
1509
+ empty for that operation.
1510
+
1511
+ Order is the declaration's, which is also the resolution order (a Jira
1512
+ issue-type id is valid only inside its project).
1513
+ """
1514
+ names: list[str] = []
1515
+ try:
1516
+ with open(os.path.join(out_dir, "index.json")) as index_file:
1517
+ entries = json.load(index_file)["entries"]
1518
+ for index_entry in entries:
1519
+ if index_entry["nodeType"] != node_type:
1520
+ continue
1521
+ if object_name is not None and index_entry.get("objectName") != object_name:
1522
+ continue
1523
+ with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1524
+ descriptor = json.load(entry_file)
1525
+ action = ((descriptor.get("operation") or {}).get("schemaAction")) or {}
1526
+ for name in action.get("parents") or []:
1527
+ if name and name not in names:
1528
+ names.append(name)
1529
+ except (OSError, KeyError, TypeError, ValueError):
1530
+ return []
1531
+ return names
1532
+
1533
+
1534
+ def _keep_prepared_schema(
1535
+ args, node_type: str, staged: str, parent_fields: dict[str, str],
1536
+ ) -> tuple[int, int] | None:
1537
+ """`(kept, discarded)` when this run must not overwrite the overlay, else `None`.
1538
+
1539
+ `--resolve` records a lookup; `-f` supplies the value a schema action reads.
1540
+ A run carrying only the former cannot have run the action, so what it has to
1541
+ write is the UNRESOLVED entry — and `merge_into` replaces by variant key, so
1542
+ it lands on top of a schema an earlier `-f` run resolved. Measured on Jira
1543
+ `create-issue`: 21 fields down to 3, taking the live-only `fields.reporter.id`
1544
+ and `fields.summary` out of `required` with it, after which `compile` accepts
1545
+ a flow it had been refusing (#666).
1546
+
1547
+ The loop that provokes it is the documented one. `check` prints
1548
+ `prepare … --resolve <field>:<by>=<value>` for every unresolved lookup, with
1549
+ no `-f`, so an author who prepares with `-f`, authors against the live schema
1550
+ and then does what `check` says destroys the schema they just authored
1551
+ against. #685 made that run say so; it still wrote.
1552
+
1553
+ Three conditions, all necessary:
1554
+
1555
+ * the EXISTING overlay declares `schemaAction.parents` — read from
1556
+ `args.out` before the merge, so this is the entry about to be replaced;
1557
+ * none of those parents arrived as `-f`, which is what makes "the action did
1558
+ not run" a fact about the inputs rather than a guess about the result;
1559
+ * the staged entry has FEWER input fields than the one on disk.
1560
+
1561
+ The third keeps this a strict no-loss rule rather than a blanket skip: equal
1562
+ or richer still merges, so a re-pulled library's fresher entry is never
1563
+ pinned by a stale overlay, and a first `--resolve` on an operation with no
1564
+ overlay writes normally (`_count_input_fields` answers `None`).
1565
+ """
1566
+ parents = _schema_action_parents(args.out, node_type, args.object)
1567
+ if not parents or any(name in parent_fields for name in parents):
1568
+ return None
1569
+ existing = _count_input_fields(args.out, node_type, args.object)
1570
+ incoming = _count_input_fields(staged, node_type, args.object)
1571
+ if existing is None or incoming is None or existing <= incoming:
1572
+ return None
1573
+ return existing, incoming
1574
+
1575
+
1576
+ def _parent_fields(
1577
+ out_dir: str, node_type: str, object_name: str | None = None
1578
+ ) -> tuple[list[str], bool]:
1579
+ """`(parent field names, whether the prepared schema is parent-ONLY)`.
1580
+
1581
+ A reference `path` carrying a `{placeholder}` is the declaration that an
1582
+ operation is parent-driven: `"/project/{fields.project.key}/issuetypes"` on
1583
+ `fields.issuetype.id` names the field whose value must be resolved first,
1584
+ and so also fixes the resolution ORDER. (Only `path` is scanned;
1585
+ `filterPattern` carries an unrelated `{filter}` template.)
1586
+
1587
+ The parent set is both halves of that relation — the placeholder names AND
1588
+ the fields that declare them — because both must be passed as `-f`.
1589
+ Reporting only the placeholders would name `fields.project.key` and omit
1590
+ `fields.issuetype.id`, the very field that declared it.
1591
+
1592
+ "Parent-only" then means every prepared field is a parent: the describe gave
1593
+ back nothing but the inputs that select the real schema. That is the
1594
+ expected shape with no `-f`, and a silent failure WITH `-f` — values that do
1595
+ not exist on the connection are accepted and resolve to exactly this.
1596
+ A resolved schema keeps its parents (Jira's 19 still include both), so
1597
+ subset-of-parents is the discriminator, not the presence of a placeholder.
1598
+ """
1599
+ names: list[str] = []
1600
+ placeholders: set[str] = set()
1601
+ declaring: set[str] = set()
1602
+ try:
1603
+ with open(os.path.join(out_dir, "index.json")) as index_file:
1604
+ entries = json.load(index_file)["entries"]
1605
+ for index_entry in entries:
1606
+ if index_entry["nodeType"] != node_type:
1607
+ continue
1608
+ if object_name is not None and index_entry.get("objectName") != object_name:
1609
+ continue
1610
+ with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1611
+ descriptor = json.load(entry_file)
1612
+ for field in descriptor.get("inputSchema", {}).get("fields", []) or []:
1613
+ name = field.get("name")
1614
+ if name and name not in names:
1615
+ names.append(name)
1616
+ path = ((field.get("reference") or {}).get("path")) or ""
1617
+ found = re.findall(r"\{([^{}]+)\}", path)
1618
+ if found:
1619
+ placeholders.update(found)
1620
+ if name:
1621
+ declaring.add(name)
1622
+ except (OSError, KeyError, TypeError, ValueError):
1623
+ return [], False
1624
+
1625
+ parents = placeholders | declaring
1626
+ if not parents:
1627
+ return [], False
1628
+ ordered = [name for name in names if name in parents]
1629
+ ordered += sorted(parents - set(ordered))
1630
+ return ordered, set(names) <= parents
1631
+
1632
+
1633
+ def _parse_fields(pairs: list[str]) -> dict[str, str]:
1634
+ """`['a=1', 'b=2']` → `{'a': '1', 'b': '2'}`, rejecting a missing `=`.
1635
+
1636
+ Values may themselves contain `=` (a Salesforce SOQL body token), so split
1637
+ on the FIRST separator only.
1638
+ """
1639
+ parsed: dict[str, str] = {}
1640
+ for pair in pairs:
1641
+ name, sep, value = pair.partition("=")
1642
+ if not sep or not name.strip():
1643
+ sys.exit(
1644
+ f"prepare-connector: --field expects NAME=VALUE, got {pair!r}."
1645
+ )
1646
+ parsed[name.strip()] = value
1647
+ return parsed
1648
+
1649
+
1650
+ def _rejected_parent_fields_message(
1651
+ pairs: list[str], failures: list[tuple] | None = None
1652
+ ) -> str:
1653
+ """Diagnose a rejected parent-field set, preserving the CLI's own message.
1654
+
1655
+ An incomplete or invalid set is refused outright — "No api-type ObjectAction
1656
+ matched for fields [...] on operation 'Create'" — which already names the
1657
+ fields it could not match. That message never reaches the caller on its own:
1658
+ `is_describe_operation` maps the failure to `None` and enrichment falls back
1659
+ to the registry-declared fields, so the overlay is written as though nothing
1660
+ went wrong. Fail here, and quote the service.
1661
+ """
1662
+ given = ", ".join(pairs)
1663
+ detail = ""
1664
+ for failure in failures or []:
1665
+ message = failure[-1]
1666
+ if message:
1667
+ detail = f" The service said: {message}"
1668
+ break
1669
+ return (
1670
+ f"prepare-connector: the parent-field set was rejected: {given}.{detail} "
1671
+ "Re-run with NO -f to list the parent fields this operation declares, "
1672
+ "resolve a value for EVERY one of them, then pass them all together. "
1673
+ "Values are connection- and parent-scoped (a Jira issue-type id is valid "
1674
+ "only inside its project), so they cannot be copied from another "
1675
+ "project, connection, or task."
1676
+ )
1677
+
1678
+
1679
+ def _input_field_note(count: int | None) -> str:
1680
+ return (
1681
+ f"{count} input field(s)"
1682
+ if count is not None
1683
+ else "input fields: <unavailable>"
1684
+ )
1685
+
1686
+
1687
+ if __name__ == "__main__":
1688
+ main()