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.
Files changed (42) hide show
  1. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/PKG-INFO +1 -1
  2. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/__init__.py +1 -1
  3. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/client.py +51 -4
  4. plugsync_cli-0.4.0/plugsync_cli/commands/diff.py +439 -0
  5. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/plugin.py +94 -7
  6. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/push.py +33 -4
  7. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/context.py +10 -1
  8. plugsync_cli-0.4.0/plugsync_cli/schema_defaults.py +183 -0
  9. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/serializer.py +202 -7
  10. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
  11. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/SOURCES.txt +3 -0
  12. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/pyproject.toml +1 -1
  13. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_api_contract.py +1 -0
  14. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_commands.py +634 -1
  15. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_e2e_draft_api.py +153 -2
  16. plugsync_cli-0.4.0/tests/test_hand_edited_yaml.py +341 -0
  17. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_plugin_commands.py +328 -0
  18. plugsync_cli-0.4.0/tests/test_schema_defaults.py +179 -0
  19. plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +0 -122
  20. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/README.md +0 -0
  21. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/bundler.py +0 -0
  22. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/__init__.py +0 -0
  23. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/auth.py +0 -0
  24. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/log.py +0 -0
  25. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/preview.py +0 -0
  26. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/pull.py +0 -0
  27. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/rollback.py +0 -0
  28. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/commands/validate.py +0 -0
  29. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/compat.py +0 -0
  30. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/config.py +0 -0
  31. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/findings.py +0 -0
  32. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli/main.py +0 -0
  33. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  34. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  35. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/requires.txt +0 -0
  36. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  37. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/setup.cfg +0 -0
  38. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_auth_commands.py +0 -0
  39. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_client.py +0 -0
  40. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_compat.py +0 -0
  41. {plugsync_cli-0.3.0 → plugsync_cli-0.4.0}/tests/test_config.py +0 -0
  42. {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.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.1.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(self, connector_id: str, change_summary: str | None = None) -> dict:
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(self, plugin_id: str, payload: dict) -> dict:
259
- """Proxy an invocation to the plugin's Lambda function."""
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={"payload": payload},
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
- console.print(f"[green]Pushed - status: {result.get('status', '?')}[/green]")
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: {info.get('id', plugin_id)}")
335
- console.print(f" status: {info.get('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: {info['bundle_hash'][:12]}...")
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='JSON payload to send (default: "{}").',
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
- def plugin_invoke_cmd(plugin_id: str, payload: str):
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