plugsync-cli 0.3.0__tar.gz → 0.4.0__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.
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/PKG-INFO +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/client.py +51 -4
- plugsync_cli-0.4.0/plugsync_cli/commands/diff.py +439 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/plugin.py +94 -7
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/push.py +33 -4
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/context.py +10 -1
- plugsync_cli-0.4.0/plugsync_cli/schema_defaults.py +183 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/serializer.py +202 -7
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/SOURCES.txt +3 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/pyproject.toml +1 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_api_contract.py +1 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_commands.py +634 -1
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_e2e_draft_api.py +153 -2
- plugsync_cli-0.4.0/tests/test_hand_edited_yaml.py +341 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_plugin_commands.py +328 -0
- plugsync_cli-0.4.0/tests/test_schema_defaults.py +179 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +0 -122
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/README.md +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/preview.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/pull.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/findings.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/main.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/setup.cfg +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_serializer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
|
|
5
5
|
Author-email: Exelab <hello@plugsync.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.4.0"
|
|
@@ -154,6 +154,19 @@ class PlugSyncClient:
|
|
|
154
154
|
r.raise_for_status()
|
|
155
155
|
return r.json()
|
|
156
156
|
|
|
157
|
+
def config_surface(self) -> dict:
|
|
158
|
+
"""The config catalogue: actions, transforms, when, sources, json_schema.
|
|
159
|
+
|
|
160
|
+
`json_schema` is `ConnectorConfigV3.model_json_schema()`, defaults
|
|
161
|
+
included: it is how `plugsync diff` knows which values the server wrote
|
|
162
|
+
from a schema default rather than the operator (#1259). Org-scoped
|
|
163
|
+
auth only -- the catalogue itself is the same for every org.
|
|
164
|
+
"""
|
|
165
|
+
with self._client() as c:
|
|
166
|
+
r = c.get(self._url("/config-surface"))
|
|
167
|
+
r.raise_for_status()
|
|
168
|
+
return r.json()
|
|
169
|
+
|
|
157
170
|
def get_version(self, connector_id: str, version: int) -> dict:
|
|
158
171
|
"""One published config version, config included (backend VersionResponse)."""
|
|
159
172
|
with self._client() as c:
|
|
@@ -175,11 +188,19 @@ class PlugSyncClient:
|
|
|
175
188
|
r.raise_for_status()
|
|
176
189
|
return r.json()
|
|
177
190
|
|
|
178
|
-
def publish(
|
|
191
|
+
def publish(
|
|
192
|
+
self, connector_id: str, change_summary: str | None = None, force: bool = False,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""`force` (#1367, DX-61): publish a new version even when the draft
|
|
195
|
+
is identical to the active one - the values a publish resolves (plugin
|
|
196
|
+
ARN/version, HubSpot schema) can drift without any draft edit. Unrelated
|
|
197
|
+
to `plugsync push --force`, which discards a concurrent DRAFT edit."""
|
|
179
198
|
with self._client() as c:
|
|
180
199
|
body = {}
|
|
181
200
|
if change_summary:
|
|
182
201
|
body["change_summary"] = change_summary
|
|
202
|
+
if force:
|
|
203
|
+
body["force"] = True
|
|
183
204
|
r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
|
|
184
205
|
r.raise_for_status()
|
|
185
206
|
return r.json()
|
|
@@ -255,12 +276,38 @@ class PlugSyncClient:
|
|
|
255
276
|
r.raise_for_status()
|
|
256
277
|
return r.json()
|
|
257
278
|
|
|
258
|
-
def plugin_invoke(
|
|
259
|
-
|
|
279
|
+
def plugin_invoke(
|
|
280
|
+
self,
|
|
281
|
+
plugin_id: str,
|
|
282
|
+
payload: dict,
|
|
283
|
+
config: dict | None = None,
|
|
284
|
+
auth_credential: str | None = None,
|
|
285
|
+
auth_key: str | None = None,
|
|
286
|
+
) -> dict:
|
|
287
|
+
"""Proxy an invocation to the plugin's Lambda function.
|
|
288
|
+
|
|
289
|
+
`payload` is the event BODY only -- it lands at `event.payload` on
|
|
290
|
+
the Lambda side, not the whole PluginPayload envelope (issue #1365).
|
|
291
|
+
`config` mirrors `event.config`.
|
|
292
|
+
|
|
293
|
+
`auth_credential` is a name, never a secret in the clear: the server
|
|
294
|
+
reads it as an event source name and resolves that source's outbound
|
|
295
|
+
auth exactly as a real flow does (its credential, auth_type and
|
|
296
|
+
auth_header), falling back to a stored credential name when the org
|
|
297
|
+
has no source called that. The event.auth KEY those headers land
|
|
298
|
+
under is `auth_key` when given, else `auth_credential` itself; that
|
|
299
|
+
key is also the `auth` capability grant the server requires on the
|
|
300
|
+
plugin, mirroring what the runtime hands a plugin.
|
|
301
|
+
"""
|
|
260
302
|
with self._client() as c:
|
|
261
303
|
r = c.post(
|
|
262
304
|
self._url(f"/plugins/{plugin_id}/invoke"),
|
|
263
|
-
json={
|
|
305
|
+
json={
|
|
306
|
+
"payload": payload,
|
|
307
|
+
"config": config or {},
|
|
308
|
+
"auth_credential": auth_credential,
|
|
309
|
+
"auth_key": auth_key,
|
|
310
|
+
},
|
|
264
311
|
)
|
|
265
312
|
r.raise_for_status()
|
|
266
313
|
return r.json()
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""plugsync diff -- compare local files against a published version (read-only).
|
|
2
|
+
|
|
3
|
+
Two bugs made this command report "no changes" no matter what (#1241). It read
|
|
4
|
+
the published side through `export_revision`, an endpoint Plan 6a deleted, and
|
|
5
|
+
it read the local side by packing the directory into a zip whose entries are
|
|
6
|
+
prefixed with the connector name while matching them at the root of the
|
|
7
|
+
archive -- so the local state was always empty, and an empty local state
|
|
8
|
+
diffs clean against anything. Both sides now compare config blobs directly:
|
|
9
|
+
the local directory as `serializer.read_config_directory` returns it, the
|
|
10
|
+
published one as `GET /versions/{v}` returns it. No zip in between.
|
|
11
|
+
|
|
12
|
+
Then the opposite failure (#1259): the comparison was `json.dumps(sort_keys=
|
|
13
|
+
True)` per item and the answer was the literal string "differs", so a config
|
|
14
|
+
whose only divergence was `on_target_deleted: "skip"` -- a schema default the
|
|
15
|
+
server materializes on every draft write -- read as "six entities modified" to
|
|
16
|
+
an operator deciding whether to publish. A false six is worse than silence.
|
|
17
|
+
|
|
18
|
+
So the diff is now field-level: it names the keys that differ, with the nested
|
|
19
|
+
path for the ones inside `schemas`/`steps`, and it separates the fields that
|
|
20
|
+
exist only on the server AND hold that field's schema default. Those are the
|
|
21
|
+
server's doing, not the operator's, so they are reported apart and left out of
|
|
22
|
+
the change count. The defaults come from the schema itself (see
|
|
23
|
+
`plugsync_cli.schema_defaults`), never from a list maintained here.
|
|
24
|
+
|
|
25
|
+
A third bug of the same shape (#1366): a flow with a `plugin` step publishes
|
|
26
|
+
with `function_arn`/`version`/`capabilities` resolved and embedded into
|
|
27
|
+
`step["plugin"]` (`app/services/plugin_resolver.py`), fields the authored
|
|
28
|
+
local form never has. Compared raw, that flow reads as permanently modified,
|
|
29
|
+
forever, even with zero edits. So the published side's plugin steps are
|
|
30
|
+
normalized against the matching local step (by step id) before the field-level
|
|
31
|
+
diff runs, stripping exactly those fields when the local step does not
|
|
32
|
+
declare them -- the same normalization `publish_service.compute_diff` already
|
|
33
|
+
does for the dashboard's draft/version diff (#690), on the same field list,
|
|
34
|
+
read from the same `GET /api/config-surface` (`plugin_embedded_fields`, see
|
|
35
|
+
`plugsync_cli.schema_defaults.plugin_embedded_fields`). A genuine drift (both
|
|
36
|
+
sides declaring the field with different values, e.g. `pull --revision`'d
|
|
37
|
+
files diffed after a plugin re-promotion) is untouched by this and still
|
|
38
|
+
surfaces as a normal change.
|
|
39
|
+
|
|
40
|
+
Read-only throughout, and no diff result is a failure: whatever this command
|
|
41
|
+
finds -- changes, materialized defaults, nothing at all -- it exits 0, because
|
|
42
|
+
it describes and never gates. `plugsync validate` is the one that fails a
|
|
43
|
+
pipeline. A non-zero exit still comes from not being able to run at all (no
|
|
44
|
+
`.plugsync.yaml`, an unreadable version, an API error), exactly as before #1259:
|
|
45
|
+
nothing here changes which situations exit non-zero.
|
|
46
|
+
"""
|
|
47
|
+
import copy
|
|
48
|
+
import json
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import click
|
|
53
|
+
from rich.console import Console
|
|
54
|
+
from rich.table import Table
|
|
55
|
+
|
|
56
|
+
from plugsync_cli.context import build_client, fail, load_context
|
|
57
|
+
from plugsync_cli.schema_defaults import SchemaNode, plugin_embedded_fields, schema_root
|
|
58
|
+
|
|
59
|
+
console = Console()
|
|
60
|
+
|
|
61
|
+
# Top-level config keys compared item-by-item (keyed by name) rather than whole.
|
|
62
|
+
COLLECTIONS = {"entities": "entity", "flows": "flow"}
|
|
63
|
+
|
|
64
|
+
# Keys listed in one Detail cell before the rest is summarized. A row exists to
|
|
65
|
+
# tell an operator where to look, and forty paths in a table cell do not.
|
|
66
|
+
MAX_LISTED_KEYS = 8
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _by_name(items) -> dict[str, dict]:
|
|
70
|
+
return {item["name"]: item for item in items or []}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _differs(left, right) -> bool:
|
|
74
|
+
return json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class FieldChange:
|
|
79
|
+
"""One key whose value is not the same on both sides.
|
|
80
|
+
|
|
81
|
+
`path` is written the way the config reads it -- `schemas.hubspot.
|
|
82
|
+
on_target_deleted`, `steps[2].action` -- so it can be followed straight
|
|
83
|
+
into the YAML file the row names.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
path: str
|
|
87
|
+
# The published value, carried only for a server default: the row prints it
|
|
88
|
+
# so the operator can see it is the default and not something surprising.
|
|
89
|
+
value: Any
|
|
90
|
+
# Absent from the local files, present in the published config. A superset
|
|
91
|
+
# of `server_default`: a key can be missing locally for other reasons.
|
|
92
|
+
only_on_server: bool
|
|
93
|
+
server_default: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _field_changes(
|
|
97
|
+
local: Any, remote: Any, node: SchemaNode, path: str = ""
|
|
98
|
+
) -> list[FieldChange]:
|
|
99
|
+
"""Every leaf that differs between two config fragments, named by path.
|
|
100
|
+
|
|
101
|
+
Lists are walked by index only when both sides are the same length: with a
|
|
102
|
+
different length there is no honest pairing (an inserted step would report
|
|
103
|
+
every later one as modified), so the list is reported as one change.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(local, dict) and isinstance(remote, dict):
|
|
106
|
+
changes: list[FieldChange] = []
|
|
107
|
+
for key in sorted(set(local) | set(remote)):
|
|
108
|
+
here = f"{path}.{key}" if path else key
|
|
109
|
+
if key not in local:
|
|
110
|
+
changes.append(_absent_locally(key, remote[key], node, here))
|
|
111
|
+
elif key not in remote:
|
|
112
|
+
changes.append(_changed(here))
|
|
113
|
+
else:
|
|
114
|
+
changes.extend(
|
|
115
|
+
_field_changes(local[key], remote[key], node.child(key), here)
|
|
116
|
+
)
|
|
117
|
+
return changes
|
|
118
|
+
|
|
119
|
+
if isinstance(local, list) and isinstance(remote, list) and len(local) == len(remote):
|
|
120
|
+
member = node.item()
|
|
121
|
+
changes = []
|
|
122
|
+
for index, (mine, theirs) in enumerate(zip(local, remote)):
|
|
123
|
+
changes.extend(_field_changes(mine, theirs, member, f"{path}[{index}]"))
|
|
124
|
+
return changes
|
|
125
|
+
|
|
126
|
+
if _differs(local, remote):
|
|
127
|
+
return [_changed(path)]
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _absent_locally(key: str, value: Any, node: SchemaNode, path: str) -> FieldChange:
|
|
132
|
+
"""A key the published config has and the local files do not."""
|
|
133
|
+
return FieldChange(path, value, True, node.is_default(key, value))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _changed(path: str) -> FieldChange:
|
|
137
|
+
"""A difference that is the operator's own: an edited or dropped value."""
|
|
138
|
+
return FieldChange(path, None, False, False)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _compact(value: Any) -> str:
|
|
142
|
+
try:
|
|
143
|
+
return json.dumps(value, sort_keys=True)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return repr(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _join(parts: list[str]) -> str:
|
|
149
|
+
if len(parts) <= MAX_LISTED_KEYS:
|
|
150
|
+
return ", ".join(parts)
|
|
151
|
+
listed = ", ".join(parts[:MAX_LISTED_KEYS])
|
|
152
|
+
return f"{listed}, and {len(parts) - MAX_LISTED_KEYS} more"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rows(prefix: str, scope: str, fields: list[FieldChange]) -> list[dict]:
|
|
156
|
+
"""Up to two rows for one item: the operator's changes, and the server's.
|
|
157
|
+
|
|
158
|
+
Split rather than merged so an item that has both -- a real edit plus a
|
|
159
|
+
materialized default -- shows the edit on its own line and in the count,
|
|
160
|
+
with the default visible but not inflating it.
|
|
161
|
+
"""
|
|
162
|
+
mine = [field for field in fields if not field.server_default]
|
|
163
|
+
theirs = [field for field in fields if field.server_default]
|
|
164
|
+
rows = []
|
|
165
|
+
if mine:
|
|
166
|
+
rows.append(
|
|
167
|
+
{
|
|
168
|
+
"type": f"{prefix}_modified",
|
|
169
|
+
"scope": scope,
|
|
170
|
+
"detail": _join([field.path for field in mine]),
|
|
171
|
+
"fields": len(mine),
|
|
172
|
+
"server_default": False,
|
|
173
|
+
# Whether naming this row correctly depended on knowing the
|
|
174
|
+
# schema: with the schema unavailable, some of these keys may
|
|
175
|
+
# be server defaults reported as the operator's changes.
|
|
176
|
+
"needs_schema": any(field.only_on_server for field in mine),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
if theirs:
|
|
180
|
+
rows.append(
|
|
181
|
+
{
|
|
182
|
+
"type": f"{prefix}_server_default",
|
|
183
|
+
"scope": scope,
|
|
184
|
+
"detail": _join(
|
|
185
|
+
[f"{field.path} = {_compact(field.value)}" for field in theirs]
|
|
186
|
+
),
|
|
187
|
+
"fields": len(theirs),
|
|
188
|
+
"server_default": True,
|
|
189
|
+
"needs_schema": False,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return rows
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
196
|
+
"""Changes that turn `remote` into `local`, one row per named item or key."""
|
|
197
|
+
changes: list[dict] = []
|
|
198
|
+
|
|
199
|
+
for key, label in COLLECTIONS.items():
|
|
200
|
+
member = root.child(key).item()
|
|
201
|
+
local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
|
|
202
|
+
for name in sorted(set(local_items) | set(remote_items)):
|
|
203
|
+
if name not in remote_items:
|
|
204
|
+
changes.append(_simple(f"{label}_added", name, "only in local files"))
|
|
205
|
+
elif name not in local_items:
|
|
206
|
+
changes.append(
|
|
207
|
+
_simple(f"{label}_removed", name, "only in the published version")
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
changes.extend(
|
|
211
|
+
_rows(
|
|
212
|
+
label,
|
|
213
|
+
name,
|
|
214
|
+
_field_changes(local_items[name], remote_items[name], member),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
scalar_keys = (set(local) | set(remote)) - set(COLLECTIONS)
|
|
219
|
+
for key in sorted(scalar_keys):
|
|
220
|
+
if key not in local:
|
|
221
|
+
fields = [_absent_locally(key, remote[key], root, key)]
|
|
222
|
+
elif key not in remote:
|
|
223
|
+
fields = [_changed(key)]
|
|
224
|
+
else:
|
|
225
|
+
fields = _field_changes(local[key], remote[key], root.child(key), key)
|
|
226
|
+
changes.extend(_rows(key, key, fields))
|
|
227
|
+
|
|
228
|
+
return changes
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _simple(change_type: str, scope: str, detail: str) -> dict:
|
|
232
|
+
return {
|
|
233
|
+
"type": change_type,
|
|
234
|
+
"scope": scope,
|
|
235
|
+
"detail": detail,
|
|
236
|
+
"fields": 1,
|
|
237
|
+
"server_default": False,
|
|
238
|
+
"needs_schema": False,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _local_item_count(config: dict) -> int:
|
|
243
|
+
collections = sum(len(config.get(key) or []) for key in COLLECTIONS)
|
|
244
|
+
scalars = sum(1 for key, value in config.items() if key not in COLLECTIONS and value)
|
|
245
|
+
return collections + scalars
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _mark(change_type: str) -> str:
|
|
249
|
+
if change_type.endswith("_added"):
|
|
250
|
+
return f"[green]+ {change_type}[/green]"
|
|
251
|
+
if change_type.endswith("_removed"):
|
|
252
|
+
return f"[red]- {change_type}[/red]"
|
|
253
|
+
if change_type.endswith("_server_default"):
|
|
254
|
+
return f"[cyan]= {change_type}[/cyan]"
|
|
255
|
+
return f"[yellow]~ {change_type}[/yellow]"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _table(changes: list[dict]) -> Table:
|
|
259
|
+
table = Table(show_header=True, header_style="bold")
|
|
260
|
+
table.add_column("Change", width=26)
|
|
261
|
+
table.add_column("Item")
|
|
262
|
+
# fold: a Detail cell now carries dotted key paths, and a path is one word
|
|
263
|
+
# to a word-wrapper. Ellipsizing it would drop the very end -- the key
|
|
264
|
+
# name -- which is the part this column exists to show.
|
|
265
|
+
table.add_column("Detail", overflow="fold")
|
|
266
|
+
for change in changes:
|
|
267
|
+
table.add_row(_mark(change["type"]), change["scope"], change["detail"])
|
|
268
|
+
return table
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render(changes: list[dict], revision: int) -> None:
|
|
272
|
+
mine = [change for change in changes if not change["server_default"]]
|
|
273
|
+
defaults = [change for change in changes if change["server_default"]]
|
|
274
|
+
|
|
275
|
+
if mine:
|
|
276
|
+
console.print(
|
|
277
|
+
f"\n[bold]{len(mine)} change{'' if len(mine) == 1 else 's'} detected[/bold]\n"
|
|
278
|
+
)
|
|
279
|
+
console.print(_table(mine))
|
|
280
|
+
else:
|
|
281
|
+
console.print(
|
|
282
|
+
f"[green]No changes of yours. Local files match published "
|
|
283
|
+
f"v{revision}.[/green]"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if defaults:
|
|
287
|
+
total = sum(change["fields"] for change in defaults)
|
|
288
|
+
console.print(
|
|
289
|
+
f"\n[bold]{total} field{'' if total == 1 else 's'} the server filled "
|
|
290
|
+
"in from a schema default[/bold], absent from your files: written on "
|
|
291
|
+
"every draft save, not changes you made\n"
|
|
292
|
+
)
|
|
293
|
+
console.print(_table(defaults))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _config_surface(client) -> dict:
|
|
297
|
+
"""The `/api/config-surface` catalog, or `{}` if it cannot be had.
|
|
298
|
+
|
|
299
|
+
Deliberately swallows everything: this command must keep working against a
|
|
300
|
+
backend with no `/api/config-surface`, one that answers it with something
|
|
301
|
+
unexpected, or none at all. Fetched exactly once per run and shared by both
|
|
302
|
+
consumers that need it -- the JSON Schema (`schema_root`) and the plugin
|
|
303
|
+
embedded-field list (`plugin_embedded_fields`) -- so a diff against an
|
|
304
|
+
unreachable API still costs one GET, not two.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
surface = client.config_surface()
|
|
308
|
+
return surface if isinstance(surface, dict) else {}
|
|
309
|
+
except Exception:
|
|
310
|
+
return {}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_plugin_embedded_fields(
|
|
314
|
+
remote_flow: dict, local_flow: dict | None, fields: frozenset[str]
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""A deep copy of `remote_flow` with publish-materialized plugin
|
|
317
|
+
coordinates dropped from any `action == "plugin"` step whose `local_flow`
|
|
318
|
+
counterpart (matched by step id) does not declare them (#1366).
|
|
319
|
+
|
|
320
|
+
Mirrors `publish_service._normalize_plugin_step_fields` (#690), the
|
|
321
|
+
backend's own fix for the same bug shape one level up (draft vs active
|
|
322
|
+
version instead of local files vs published version). Scoped strictly to
|
|
323
|
+
`step["plugin"]` on `action == "plugin"` steps and matched by step id, so a
|
|
324
|
+
real drift -- both sides declaring the field, with different values --
|
|
325
|
+
still surfaces as a normal change.
|
|
326
|
+
"""
|
|
327
|
+
if not fields:
|
|
328
|
+
return remote_flow
|
|
329
|
+
local_steps_by_id = {
|
|
330
|
+
s["id"]: s
|
|
331
|
+
for s in (local_flow or {}).get("steps", []) or []
|
|
332
|
+
if isinstance(s, dict) and s.get("id")
|
|
333
|
+
}
|
|
334
|
+
normalized = copy.deepcopy(remote_flow)
|
|
335
|
+
for step in normalized.get("steps", []) or []:
|
|
336
|
+
if step.get("action") != "plugin" or not step.get("id"):
|
|
337
|
+
continue
|
|
338
|
+
plugin_block = step.get("plugin")
|
|
339
|
+
if not isinstance(plugin_block, dict):
|
|
340
|
+
continue
|
|
341
|
+
local_step = local_steps_by_id.get(step["id"]) or {}
|
|
342
|
+
local_plugin_block = local_step.get("plugin")
|
|
343
|
+
if not isinstance(local_plugin_block, dict):
|
|
344
|
+
local_plugin_block = {}
|
|
345
|
+
for field_name in fields:
|
|
346
|
+
if field_name in plugin_block and field_name not in local_plugin_block:
|
|
347
|
+
del plugin_block[field_name]
|
|
348
|
+
return normalized
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _normalize_remote_flows(
|
|
352
|
+
remote_flows: list, local_flows: list, fields: frozenset[str]
|
|
353
|
+
) -> list:
|
|
354
|
+
local_by_name = {f["name"]: f for f in local_flows or [] if isinstance(f, dict)}
|
|
355
|
+
return [
|
|
356
|
+
_strip_plugin_embedded_fields(f, local_by_name.get(f.get("name")), fields)
|
|
357
|
+
for f in remote_flows or []
|
|
358
|
+
if isinstance(f, dict)
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _warn_defaults_unknown(changes: list[dict]) -> None:
|
|
363
|
+
"""Say why a change may not be one, but only when the doubt is real.
|
|
364
|
+
|
|
365
|
+
Fires only if the schema was unavailable AND something differs solely by
|
|
366
|
+
being absent locally: that is the shape a materialized default has. Any
|
|
367
|
+
other diff is unaffected by the missing schema and must not drag a warning
|
|
368
|
+
an operator would learn to skip.
|
|
369
|
+
"""
|
|
370
|
+
if not any(change["needs_schema"] for change in changes):
|
|
371
|
+
return
|
|
372
|
+
console.print(
|
|
373
|
+
"\n[yellow]Some keys below exist only in the published version. This "
|
|
374
|
+
"API does not serve the config schema (GET /api/config-surface), so "
|
|
375
|
+
"the ones the server materialized from a schema default cannot be told "
|
|
376
|
+
"apart from your own edits. Upgrade the API to see them separated."
|
|
377
|
+
"[/yellow]"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@click.command()
|
|
382
|
+
@click.argument("path", required=False, default=None)
|
|
383
|
+
@click.option(
|
|
384
|
+
"--revision",
|
|
385
|
+
"-r",
|
|
386
|
+
type=int,
|
|
387
|
+
default=None,
|
|
388
|
+
help="Compare against a specific published version (default: the newest)",
|
|
389
|
+
)
|
|
390
|
+
def diff(path: str | None, revision: int | None):
|
|
391
|
+
"""Show what differs between local files and a published version.
|
|
392
|
+
|
|
393
|
+
Read-only: nothing is uploaded. To see what a publish would change instead,
|
|
394
|
+
use `plugsync preview`, which diffs the DRAFT against the active version.
|
|
395
|
+
|
|
396
|
+
Each modified item names the keys that differ. Keys the server wrote from a
|
|
397
|
+
schema default (`on_target_deleted`, `pipelines`, ...) are listed
|
|
398
|
+
separately and excluded from the change count: they are not your edits.
|
|
399
|
+
|
|
400
|
+
Examples:
|
|
401
|
+
plugsync diff
|
|
402
|
+
plugsync diff ./hubspot-juve
|
|
403
|
+
plugsync diff --revision 3
|
|
404
|
+
"""
|
|
405
|
+
client = build_client()
|
|
406
|
+
context = load_context(path)
|
|
407
|
+
local_config = context.config()
|
|
408
|
+
|
|
409
|
+
if revision is None:
|
|
410
|
+
versions = client.list_revisions(context.connector_id)
|
|
411
|
+
if not versions:
|
|
412
|
+
console.print("[yellow]No published versions yet. Everything is new.[/yellow]")
|
|
413
|
+
count = _local_item_count(local_config)
|
|
414
|
+
console.print(f"[bold]{count} local item(s) would be published.[/bold]")
|
|
415
|
+
return
|
|
416
|
+
revision = versions[0]["version"]
|
|
417
|
+
|
|
418
|
+
remote_config = client.get_version(context.connector_id, revision)["config"]
|
|
419
|
+
if not isinstance(remote_config, dict):
|
|
420
|
+
fail(f"Version {revision} carries no readable config.")
|
|
421
|
+
|
|
422
|
+
console.print(f"Comparing local files against published v{revision}...")
|
|
423
|
+
surface = _config_surface(client)
|
|
424
|
+
root = schema_root(surface.get("json_schema"))
|
|
425
|
+
fields = plugin_embedded_fields(surface)
|
|
426
|
+
if isinstance(remote_config.get("flows"), list):
|
|
427
|
+
remote_config = {
|
|
428
|
+
**remote_config,
|
|
429
|
+
"flows": _normalize_remote_flows(
|
|
430
|
+
remote_config["flows"], local_config.get("flows"), fields
|
|
431
|
+
),
|
|
432
|
+
}
|
|
433
|
+
changes = _diff_configs(local_config, remote_config, root)
|
|
434
|
+
if not changes:
|
|
435
|
+
console.print(f"[green]No changes. Local files match published v{revision}.[/green]")
|
|
436
|
+
return
|
|
437
|
+
_render(changes, revision)
|
|
438
|
+
if not root.known:
|
|
439
|
+
_warn_defaults_unknown(changes)
|
|
@@ -232,7 +232,29 @@ def plugin_push(path: str | None):
|
|
|
232
232
|
result = client.plugin_push(plugin_id, bundle_bytes, source_code)
|
|
233
233
|
except httpx.HTTPStatusError as exc:
|
|
234
234
|
_handle_plugin_api_error(exc)
|
|
235
|
-
|
|
235
|
+
|
|
236
|
+
# `result["status"]` is the plugin's PROMOTION status (draft/dev/staging/live/...),
|
|
237
|
+
# not the outcome of this push -- push never writes Plugin.status itself
|
|
238
|
+
# (backend/app/api/plugins.py's push_plugin only stores the bundle and enqueues a
|
|
239
|
+
# deploy_plugin_dev outbox row). Reporting it bare as "Pushed - status: live" reads
|
|
240
|
+
# as "this push went live", which is false whenever the plugin was already promoted
|
|
241
|
+
# before this push (issue #1364/DX-58). Spell out what actually happened instead.
|
|
242
|
+
live_str = result.get("live_version") or "none"
|
|
243
|
+
staging_str = result.get("staging_version") or "none"
|
|
244
|
+
|
|
245
|
+
# Kept as short, separate print() calls on purpose: Rich wraps a single long
|
|
246
|
+
# logical line at the terminal width (80 cols when not a tty), which would
|
|
247
|
+
# otherwise fold a real plugin_id (a 36-char UUID) mid-command and make the
|
|
248
|
+
# printed `plugsync plugin status <id>` hint uncopy-pasteable.
|
|
249
|
+
console.print("[green]Bundle uploaded, dev deploy enqueued.[/green]")
|
|
250
|
+
console.print(
|
|
251
|
+
f"Promotion status: {result.get('status', '?')} "
|
|
252
|
+
f"(live_version {live_str}, staging_version {staging_str})."
|
|
253
|
+
)
|
|
254
|
+
console.print("Run `plugsync plugin promote` to ship it.")
|
|
255
|
+
console.print("Dev deploy runs asynchronously; it may take a few seconds.")
|
|
256
|
+
console.print(f"Check outcome: `plugsync plugin status {plugin_id}`")
|
|
257
|
+
console.print(f"or `plugsync plugin logs {plugin_id}`.")
|
|
236
258
|
|
|
237
259
|
|
|
238
260
|
# ---------------------------------------------------------------------------
|
|
@@ -331,10 +353,15 @@ def plugin_status_cmd(plugin_id: str):
|
|
|
331
353
|
|
|
332
354
|
info = client.plugin_status(plugin_id)
|
|
333
355
|
console.print(f"[bold]{info.get('name', plugin_id)}[/bold]")
|
|
334
|
-
console.print(f" id:
|
|
335
|
-
console.print(f" status:
|
|
356
|
+
console.print(f" id: {info.get('id', plugin_id)}")
|
|
357
|
+
console.print(f" status: {info.get('status', '?')}")
|
|
358
|
+
console.print(f" staging_version: {info.get('staging_version') or 'none'}")
|
|
359
|
+
console.print(f" live_version: {info.get('live_version') or 'none'}")
|
|
360
|
+
console.print(f" function_arn: {info.get('function_arn') or 'none'}")
|
|
361
|
+
if info.get("error_message"):
|
|
362
|
+
console.print(f" error_message: {info['error_message']}")
|
|
336
363
|
if info.get("bundle_hash"):
|
|
337
|
-
console.print(f" hash:
|
|
364
|
+
console.print(f" hash: {info['bundle_hash'][:12]}...")
|
|
338
365
|
|
|
339
366
|
|
|
340
367
|
# ---------------------------------------------------------------------------
|
|
@@ -422,13 +449,49 @@ def plugin_metrics_cmd(plugin_id: str, window: str):
|
|
|
422
449
|
"--payload",
|
|
423
450
|
"-p",
|
|
424
451
|
default="{}",
|
|
425
|
-
help=
|
|
452
|
+
help=(
|
|
453
|
+
'JSON event BODY to send (default: "{}"). This is only the '
|
|
454
|
+
"`payload` key of the PluginPayload your handler receives -- it "
|
|
455
|
+
"lands at event.payload, NOT the whole envelope. Use --config and "
|
|
456
|
+
"--auth for the other keys a plugin can read."
|
|
457
|
+
),
|
|
426
458
|
)
|
|
427
|
-
|
|
459
|
+
@click.option(
|
|
460
|
+
"--config",
|
|
461
|
+
"-c",
|
|
462
|
+
default=None,
|
|
463
|
+
help=(
|
|
464
|
+
"JSON object to send as event.config, mirroring a step's authored "
|
|
465
|
+
"`plugin.config` (default: {})."
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
@click.option(
|
|
469
|
+
"--auth",
|
|
470
|
+
"-a",
|
|
471
|
+
"auth",
|
|
472
|
+
default=None,
|
|
473
|
+
help=(
|
|
474
|
+
"Name of an event source (NOT a secret) whose outbound auth the "
|
|
475
|
+
"server resolves into headers, exactly as a real flow resolves it: "
|
|
476
|
+
"the source's own credential, auth_type and auth_header. The "
|
|
477
|
+
"headers land at event.auth[<source-name>], the key a real `auth` "
|
|
478
|
+
"capability grant uses, and the plugin must declare that grant "
|
|
479
|
+
"(`auth:<source-name>`) or the invoke is refused. Two more forms: "
|
|
480
|
+
"`<credential-name>`, used when no source of your org carries that "
|
|
481
|
+
"name, resolves a stored credential with the default header for "
|
|
482
|
+
"its type; `<source-name>=<credential-name>` pins that credential "
|
|
483
|
+
"onto the named source's header shape."
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
def plugin_invoke_cmd(plugin_id: str, payload: str, config: str | None, auth: str | None):
|
|
428
487
|
"""Invoke a plugin with a test payload.
|
|
429
488
|
|
|
430
489
|
Example:
|
|
431
490
|
plugsync plugin invoke <id> --payload '{"key": "value"}'
|
|
491
|
+
plugsync plugin invoke <id> --payload '{"key": "value"}' \\
|
|
492
|
+
--config '{"doc_type": "invoice"}' --auth kvk
|
|
493
|
+
plugsync plugin invoke <id> --payload '{"key": "value"}' \\
|
|
494
|
+
--auth kvk=erp_api_key
|
|
432
495
|
"""
|
|
433
496
|
try:
|
|
434
497
|
payload_dict = json.loads(payload)
|
|
@@ -436,13 +499,37 @@ def plugin_invoke_cmd(plugin_id: str, payload: str):
|
|
|
436
499
|
console.print(f"[red]Invalid JSON payload: {e}[/red]")
|
|
437
500
|
raise SystemExit(1)
|
|
438
501
|
|
|
502
|
+
config_dict: dict = {}
|
|
503
|
+
if config is not None:
|
|
504
|
+
try:
|
|
505
|
+
config_dict = json.loads(config)
|
|
506
|
+
except json.JSONDecodeError as e:
|
|
507
|
+
console.print(f"[red]Invalid JSON config: {e}[/red]")
|
|
508
|
+
raise SystemExit(1)
|
|
509
|
+
|
|
510
|
+
# `--auth <name>` (a source name, falling back server-side to a credential
|
|
511
|
+
# name when the org has no source called that) or `--auth <source>=<cred>`
|
|
512
|
+
# (an explicit credential pinned onto that source's header shape). Either
|
|
513
|
+
# way the part before the "=" -- or the whole value -- is the event.auth
|
|
514
|
+
# key AND the `auth` capability grant the server requires.
|
|
515
|
+
auth_credential = auth
|
|
516
|
+
auth_key: str | None = None
|
|
517
|
+
if auth is not None and "=" in auth:
|
|
518
|
+
auth_key, auth_credential = auth.split("=", 1)
|
|
519
|
+
if not auth_key or not auth_credential:
|
|
520
|
+
console.print(
|
|
521
|
+
"[red]Invalid --auth: expected <source-name>=<credential-name>, "
|
|
522
|
+
f"got {auth!r}[/red]"
|
|
523
|
+
)
|
|
524
|
+
raise SystemExit(1)
|
|
525
|
+
|
|
439
526
|
try:
|
|
440
527
|
client = PlugSyncClient()
|
|
441
528
|
except RuntimeError as e:
|
|
442
529
|
console.print(f"[red]{e}[/red]")
|
|
443
530
|
raise SystemExit(1)
|
|
444
531
|
|
|
445
|
-
result = client.plugin_invoke(plugin_id, payload_dict)
|
|
532
|
+
result = client.plugin_invoke(plugin_id, payload_dict, config_dict, auth_credential, auth_key)
|
|
446
533
|
console.print(json.dumps(result, indent=2))
|
|
447
534
|
|
|
448
535
|
|