plugsync-cli 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plugsync_cli/client.py CHANGED
@@ -1,10 +1,81 @@
1
1
  """HTTP client wrapper for PluSync REST API."""
2
+ import json
3
+
2
4
  import httpx
3
5
 
4
6
  from plugsync_cli.compat import check_api_compat
5
7
  from plugsync_cli.config import get_api_key, get_api_url
6
8
 
7
9
 
10
+ class DraftConflict(Exception):
11
+ """The draft moved on since the version the caller read.
12
+
13
+ Carries the draft version the server currently holds, so a command can tell
14
+ the user what to re-pull instead of surfacing a bare 409.
15
+ """
16
+
17
+ def __init__(self, current_version: int | None):
18
+ self.current_version = current_version
19
+ super().__init__(
20
+ "the connector draft changed since it was pulled"
21
+ + (f" (server is at draft v{current_version})" if current_version else "")
22
+ )
23
+
24
+
25
+ def _render_detail(detail) -> str:
26
+ """The backend's `detail` as one readable block.
27
+
28
+ FastAPI answers a string for a hand-raised HTTPException, a dict for the
29
+ structured ones (draft conflicts, upgrade payloads), and a list of
30
+ per-field entries for a request-validation failure. The last one carries
31
+ the precise Pydantic error, which is exactly what an operator who mistyped
32
+ a config key needs to see.
33
+ """
34
+ if isinstance(detail, str):
35
+ return detail
36
+ if isinstance(detail, list):
37
+ lines = []
38
+ for entry in detail:
39
+ if isinstance(entry, dict) and "msg" in entry:
40
+ location = ".".join(str(part) for part in entry.get("loc", []))
41
+ lines.append(f"{location}: {entry['msg']}" if location else entry["msg"])
42
+ else:
43
+ lines.append(json.dumps(entry, ensure_ascii=False))
44
+ return "\n ".join([""] + lines)
45
+ return json.dumps(detail, ensure_ascii=False, indent=2)
46
+
47
+
48
+ def describe_http_error(exc: httpx.HTTPStatusError) -> str:
49
+ """A non-2xx turned into something an operator can act on.
50
+
51
+ Without this the CLI dies on the raw `raise_for_status()` traceback and
52
+ throws away the response body, which is where the backend says what is
53
+ actually wrong.
54
+ """
55
+ response, request = exc.response, exc.request
56
+ summary = (
57
+ f"{request.method} {request.url.path} failed "
58
+ f"(HTTP {response.status_code} {response.reason_phrase})"
59
+ )
60
+ try:
61
+ payload = response.json()
62
+ except ValueError:
63
+ body = (response.text or "").strip()
64
+ return f"{summary}: {body}" if body else summary
65
+
66
+ detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
67
+ return f"{summary}: {_render_detail(detail)}"
68
+
69
+
70
+ def _conflict_version(response: httpx.Response) -> int | None:
71
+ """The `current_version` the draft API reports in its 409 detail."""
72
+ try:
73
+ detail = response.json().get("detail")
74
+ except ValueError:
75
+ return None
76
+ return detail.get("current_version") if isinstance(detail, dict) else None
77
+
78
+
8
79
  class PlugSyncClient:
9
80
  """Thin wrapper around httpx for PluSync API calls."""
10
81
 
@@ -49,36 +120,58 @@ class PlugSyncClient:
49
120
  r.raise_for_status()
50
121
  return r.json()
51
122
 
52
- def export_working_state(self, connector_id: str) -> bytes:
123
+ def get_draft(self, connector_id: str) -> dict:
124
+ """The connector's editable config (backend DraftResponse).
125
+
126
+ The draft is the CLI's read AND write surface for the current config:
127
+ there is no server-side export, the config travels as JSON and the
128
+ directory format is produced locally by `serializer.py` (#1241).
129
+ """
53
130
  with self._client() as c:
54
- r = c.get(self._url(f"/connectors/{connector_id}/export"))
131
+ r = c.get(self._url(f"/connectors/{connector_id}/draft"))
55
132
  r.raise_for_status()
56
- return r.content
133
+ return r.json()
57
134
 
58
- def export_revision(self, connector_id: str, version: int) -> bytes:
135
+ def put_draft(
136
+ self, connector_id: str, config: dict, expected_version: int | None = None
137
+ ) -> dict:
138
+ """Replace the whole draft config. Never publishes.
139
+
140
+ `expected_version` is the `draft_version` the caller last read: the
141
+ backend rejects the write with 409 if the draft moved on since, which
142
+ is what keeps a `pull` -> edit -> `push` cycle from silently discarding
143
+ someone else's dashboard edit. Raises `DraftConflict` on that 409.
144
+ """
145
+ params = {} if expected_version is None else {"expected_version": expected_version}
59
146
  with self._client() as c:
60
- r = c.get(self._url(f"/connectors/{connector_id}/revisions/{version}/export"))
147
+ r = c.put(
148
+ self._url(f"/connectors/{connector_id}/draft"),
149
+ json=config,
150
+ params=params,
151
+ )
152
+ if r.status_code == 409:
153
+ raise DraftConflict(_conflict_version(r))
61
154
  r.raise_for_status()
62
- return r.content
155
+ return r.json()
63
156
 
64
- def import_zip(self, connector_id: str, zip_data: bytes) -> dict:
157
+ def get_version(self, connector_id: str, version: int) -> dict:
158
+ """One published config version, config included (backend VersionResponse)."""
65
159
  with self._client() as c:
66
- r = c.post(
67
- self._url(f"/connectors/{connector_id}/import"),
68
- files={"file": ("connector.zip", zip_data, "application/zip")},
69
- )
160
+ r = c.get(self._url(f"/connectors/{connector_id}/versions/{version}"))
70
161
  r.raise_for_status()
71
162
  return r.json()
72
163
 
73
- def validate(self, connector_id: str) -> dict:
164
+ def preflight(self, connector_id: str) -> dict:
165
+ """Preflight findings on the draft, split into errors/warnings/infos."""
74
166
  with self._client() as c:
75
- r = c.post(self._url(f"/connectors/{connector_id}/validate"))
167
+ r = c.get(self._url(f"/connectors/{connector_id}/preflight"))
76
168
  r.raise_for_status()
77
169
  return r.json()
78
170
 
79
171
  def preview(self, connector_id: str) -> dict:
172
+ """What publishing the draft would change against the active version."""
80
173
  with self._client() as c:
81
- r = c.post(self._url(f"/connectors/{connector_id}/preview"))
174
+ r = c.get(self._url(f"/connectors/{connector_id}/preview"))
82
175
  r.raise_for_status()
83
176
  return r.json()
84
177
 
@@ -92,14 +185,24 @@ class PlugSyncClient:
92
185
  return r.json()
93
186
 
94
187
  def list_revisions(self, connector_id: str) -> list[dict]:
188
+ """Published config versions, newest first (backend: app/api/versions.py).
189
+
190
+ The endpoint is `/versions`: Plan 6a replaced the `/revisions` API the
191
+ CLI used to call, which is why `plugsync log` answered 404 (#1244).
192
+ """
95
193
  with self._client() as c:
96
- r = c.get(self._url(f"/connectors/{connector_id}/revisions"))
194
+ r = c.get(self._url(f"/connectors/{connector_id}/versions"))
97
195
  r.raise_for_status()
98
196
  return r.json()
99
197
 
100
198
  def restore_revision(self, connector_id: str, version: int) -> dict:
199
+ """Copy a published version back into the draft, returning the draft.
200
+
201
+ Does not publish: the restored config becomes the connector's draft and
202
+ needs an explicit publish to go live.
203
+ """
101
204
  with self._client() as c:
102
- r = c.post(self._url(f"/connectors/{connector_id}/revisions/{version}/restore"))
205
+ r = c.post(self._url(f"/connectors/{connector_id}/versions/{version}/restore"))
103
206
  r.raise_for_status()
104
207
  return r.json()
105
208
 
@@ -125,6 +125,10 @@ def auth_login(email: str | None, password: str | None, token: str | None):
125
125
  raise SystemExit(1)
126
126
  except httpx.RequestError as exc:
127
127
  console.print(f"[red]Could not reach {api_url}: {exc}[/red]")
128
+ console.print(
129
+ "[yellow]Is this the right API URL? Set it with "
130
+ "'plugsync config set api_url <url>'.[/yellow]"
131
+ )
128
132
  raise SystemExit(1)
129
133
 
130
134
  data = response.json()
@@ -1,169 +1,122 @@
1
- """plugsync diff — compare local files vs published revision (read-only)."""
2
- from __future__ import annotations
3
-
4
- import io
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
+ """
5
12
  import json
6
- import zipfile
7
- from pathlib import Path
8
13
 
9
14
  import click
10
- import yaml
11
15
  from rich.console import Console
12
16
  from rich.table import Table
13
17
 
14
- from plugsync_cli.client import PlugSyncClient
15
- from plugsync_cli.serializer import read_directory_to_zip
18
+ from plugsync_cli.context import build_client, fail, load_context
16
19
 
17
20
  console = Console()
18
21
 
22
+ # Top-level config keys compared item-by-item (keyed by name) rather than whole.
23
+ COLLECTIONS = {"entities": "entity", "flows": "flow"}
19
24
 
20
- def _resolve_context(path: str | None) -> tuple[Path, str]:
21
- """Resolve connector dir and connector_id."""
22
- connector_dir = Path(path) if path else Path.cwd()
23
- if not (connector_dir / "plugsync.yaml").exists():
24
- console.print("[red]No plugsync.yaml found. Run from a connector directory or specify path.[/red]")
25
- raise SystemExit(1)
26
25
 
27
- local_config = connector_dir / ".plugsync.yaml"
28
- if not local_config.exists():
29
- console.print("[red]No .plugsync.yaml found. Run 'plugsync pull' first.[/red]")
30
- raise SystemExit(1)
26
+ def _by_name(items) -> dict[str, dict]:
27
+ return {item["name"]: item for item in items or []}
31
28
 
32
- with open(local_config) as f:
33
- data = yaml.safe_load(f) or {}
34
- connector_id = data.get("connector_id")
35
- if not connector_id:
36
- console.print("[red]No connector_id in .plugsync.yaml[/red]")
37
- raise SystemExit(1)
38
29
 
39
- return connector_dir, connector_id
30
+ def _differs(left, right) -> bool:
31
+ return json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True)
40
32
 
41
33
 
42
- def _read_zip_contents(zip_data: bytes) -> dict:
43
- """Extract settings, entities, and handlers from a connector zip."""
44
- settings: dict = {}
45
- entities: dict[str, dict] = {}
46
- handlers: dict[str, str] = {}
34
+ def _diff_configs(local: dict, remote: dict) -> list[dict]:
35
+ """Changes that turn `remote` into `local`, one row per named item or key."""
36
+ changes: list[dict] = []
47
37
 
48
- with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
49
- for name in zf.namelist():
50
- if name == "plugsync.yaml" or name == "settings.yaml":
51
- raw = yaml.safe_load(zf.read(name)) or {}
52
- settings = raw
53
- elif name.startswith("entities/") and name.endswith(".yaml"):
54
- entity_name = Path(name).stem
55
- entities[entity_name] = yaml.safe_load(zf.read(name)) or {}
56
- elif name.startswith("handlers/") and name.endswith(".py"):
57
- handler_name = Path(name).stem
58
- handlers[handler_name] = zf.read(name).decode()
38
+ for key, label in COLLECTIONS.items():
39
+ local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
40
+ for name in sorted(set(local_items) | set(remote_items)):
41
+ if name not in remote_items:
42
+ changes.append({"type": f"{label}_added", "scope": name, "detail": "only in local files"})
43
+ elif name not in local_items:
44
+ changes.append({"type": f"{label}_removed", "scope": name, "detail": "only in the published version"})
45
+ elif _differs(local_items[name], remote_items[name]):
46
+ changes.append({"type": f"{label}_modified", "scope": name, "detail": "differs"})
47
+
48
+ scalar_keys = (set(local) | set(remote)) - set(COLLECTIONS)
49
+ for key in sorted(scalar_keys):
50
+ if _differs(local.get(key), remote.get(key)):
51
+ changes.append({"type": f"{key}_modified", "scope": key, "detail": "differs"})
59
52
 
60
- return {"settings": settings, "entities": entities, "handlers": handlers}
53
+ return changes
61
54
 
62
55
 
63
- def _diff_states(local: dict, remote: dict) -> list[dict]:
64
- """Compare local and remote states, return list of changes."""
65
- changes: list[dict] = []
56
+ def _local_item_count(config: dict) -> int:
57
+ collections = sum(len(config.get(key) or []) for key in COLLECTIONS)
58
+ scalars = sum(1 for key, value in config.items() if key not in COLLECTIONS and value)
59
+ return collections + scalars
66
60
 
67
- # Settings
68
- if json.dumps(local["settings"], sort_keys=True) != json.dumps(remote["settings"], sort_keys=True):
69
- changes.append({"type": "settings_modified", "detail": "Settings changed", "entity": "", "handler": ""})
70
-
71
- # Entities
72
- all_entities = set(local["entities"].keys()) | set(remote["entities"].keys())
73
- for name in sorted(all_entities):
74
- if name in local["entities"] and name not in remote["entities"]:
75
- changes.append({"type": "entity_added", "detail": f"Entity '{name}' added", "entity": name, "handler": ""})
76
- elif name not in local["entities"] and name in remote["entities"]:
77
- changes.append({"type": "entity_removed", "detail": f"Entity '{name}' removed", "entity": name, "handler": ""})
78
- else:
79
- if json.dumps(local["entities"][name], sort_keys=True) != json.dumps(remote["entities"][name], sort_keys=True):
80
- changes.append({"type": "entity_modified", "detail": f"Entity '{name}' modified", "entity": name, "handler": ""})
81
-
82
- # Handlers
83
- all_handlers = set(local["handlers"].keys()) | set(remote["handlers"].keys())
84
- for name in sorted(all_handlers):
85
- if name in local["handlers"] and name not in remote["handlers"]:
86
- changes.append({"type": "handler_added", "detail": f"Handler '{name}' added", "entity": "", "handler": name})
87
- elif name not in local["handlers"] and name in remote["handlers"]:
88
- changes.append({"type": "handler_removed", "detail": f"Handler '{name}' removed", "entity": "", "handler": name})
89
- else:
90
- if local["handlers"][name] != remote["handlers"][name]:
91
- changes.append({"type": "handler_modified", "detail": f"Handler '{name}' modified", "entity": "", "handler": name})
92
61
 
93
- return changes
62
+ def _render(changes: list[dict]) -> None:
63
+ console.print(f"\n[bold]{len(changes)} change{'' if len(changes) == 1 else 's'} detected[/bold]\n")
64
+ table = Table(show_header=True, header_style="bold")
65
+ table.add_column("Change", width=24)
66
+ table.add_column("Item")
67
+ table.add_column("Detail")
68
+ for change in changes:
69
+ change_type = change["type"]
70
+ if change_type.endswith("_added"):
71
+ rendered = f"[green]+ {change_type}[/green]"
72
+ elif change_type.endswith("_removed"):
73
+ rendered = f"[red]- {change_type}[/red]"
74
+ else:
75
+ rendered = f"[yellow]~ {change_type}[/yellow]"
76
+ table.add_row(rendered, change["scope"], change["detail"])
77
+ console.print(table)
94
78
 
95
79
 
96
80
  @click.command()
97
81
  @click.argument("path", required=False, default=None)
98
- def diff(path: str | None):
99
- """Show what changed between local files and published revision.
100
-
101
- Compares local files against the remote published state WITHOUT
102
- uploading anything. This is a read-only operation.
82
+ @click.option(
83
+ "--revision",
84
+ "-r",
85
+ type=int,
86
+ default=None,
87
+ help="Compare against a specific published version (default: the newest)",
88
+ )
89
+ def diff(path: str | None, revision: int | None):
90
+ """Show what differs between local files and a published version.
91
+
92
+ Read-only: nothing is uploaded. To see what a publish would change instead,
93
+ use `plugsync preview`, which diffs the DRAFT against the active version.
103
94
 
104
95
  Examples:
105
96
  plugsync diff
106
97
  plugsync diff ./hubspot-juve
98
+ plugsync diff --revision 3
107
99
  """
108
- try:
109
- client = PlugSyncClient()
110
- except RuntimeError as e:
111
- console.print(f"[red]{e}[/red]")
112
- raise SystemExit(1)
113
-
114
- connector_dir, connector_id = _resolve_context(path)
115
-
116
- # Read local files into a zip and parse them
117
- local_zip = read_directory_to_zip(connector_dir)
118
- local_state = _read_zip_contents(local_zip)
119
-
120
- # Get the latest revision number from remote
121
- revisions = client.list_revisions(connector_id)
122
- if not revisions:
123
- console.print("[yellow]No published revisions yet. Everything is new.[/yellow]")
124
- n = len(local_state["entities"]) + len(local_state["handlers"])
125
- if local_state["settings"]:
126
- n += 1
127
- console.print(f"[bold]{n} local item(s) would be published.[/bold]")
128
- return
129
-
130
- latest_version = revisions[0]["version"]
131
- remote_zip = client.export_revision(connector_id, latest_version)
132
- remote_state = _read_zip_contents(remote_zip)
133
-
134
- changes = _diff_states(local_state, remote_state)
135
-
100
+ client = build_client()
101
+ context = load_context(path)
102
+ local_config = context.config()
103
+
104
+ if revision is None:
105
+ versions = client.list_revisions(context.connector_id)
106
+ if not versions:
107
+ console.print("[yellow]No published versions yet. Everything is new.[/yellow]")
108
+ count = _local_item_count(local_config)
109
+ console.print(f"[bold]{count} local item(s) would be published.[/bold]")
110
+ return
111
+ revision = versions[0]["version"]
112
+
113
+ remote_config = client.get_version(context.connector_id, revision)["config"]
114
+ if not isinstance(remote_config, dict):
115
+ fail(f"Version {revision} carries no readable config.")
116
+
117
+ console.print(f"Comparing local files against published v{revision}...")
118
+ changes = _diff_configs(local_config, remote_config)
136
119
  if not changes:
137
- console.print("[green]No changes. Local files match published revision.[/green]")
120
+ console.print(f"[green]No changes. Local files match published v{revision}.[/green]")
138
121
  return
139
-
140
- summary = f"{len(changes)} change{'s' if len(changes) != 1 else ''} detected"
141
- console.print(f"\n[bold]{summary}[/bold]\n")
142
-
143
- table = Table(show_header=True, header_style="bold")
144
- table.add_column("Type", style="cyan", width=20)
145
- table.add_column("Detail")
146
-
147
- for change in changes:
148
- change_type = change.get("type", "unknown")
149
- detail = change.get("detail", "")
150
- entity = change.get("entity", "")
151
- handler = change.get("handler", "")
152
-
153
- label = entity or handler or ""
154
- if label:
155
- detail = f"[bold]{label}[/bold]: {detail}"
156
-
157
- # Color by type
158
- if "added" in change_type:
159
- type_style = "[green]+ added[/green]"
160
- elif "removed" in change_type:
161
- type_style = "[red]- removed[/red]"
162
- elif "modified" in change_type:
163
- type_style = "[yellow]~ modified[/yellow]"
164
- else:
165
- type_style = change_type
166
-
167
- table.add_row(type_style, detail)
168
-
169
- console.print(table)
122
+ _render(changes)
@@ -1,107 +1,68 @@
1
- """plugsync preview — preview changes with simulation."""
2
- from pathlib import Path
3
-
1
+ """plugsync preview -- what publishing the draft would change."""
4
2
  import click
5
- import yaml
6
3
  from rich.console import Console
7
- from rich.panel import Panel
8
4
  from rich.table import Table
9
5
 
10
- from plugsync_cli.client import PlugSyncClient
11
- from plugsync_cli.serializer import read_directory_to_zip
6
+ from plugsync_cli.context import (
7
+ build_client,
8
+ load_context,
9
+ warn_if_local_differs_from_draft,
10
+ )
11
+ from plugsync_cli.findings import render_preflight
12
12
 
13
13
  console = Console()
14
14
 
15
+ # Rendering per change type, keyed by the verb the backend's ChangeDetail uses.
16
+ _CHANGE_MARKS = (("added", "+", "green"), ("removed", "-", "red"), ("modified", "~", "yellow"))
17
+
18
+
19
+ def _mark(change_type: str) -> str:
20
+ for verb, glyph, colour in _CHANGE_MARKS:
21
+ if verb in change_type:
22
+ return f"[{colour}]{glyph} {change_type}[/{colour}]"
23
+ return change_type
24
+
15
25
 
16
26
  @click.command()
17
27
  @click.argument("path", required=False, default=None)
18
28
  def preview(path: str | None):
19
- """Preview what would change if you publish now.
29
+ """Preview the diff a publish would apply, plus its preflight findings.
30
+
31
+ Read-only: this uploads nothing and publishes nothing. Both halves are
32
+ server-side views of the DRAFT (`GET /preview` for the diff against the
33
+ active version, `GET /preflight` for the findings), so the command warns
34
+ when the local files differ from the draft it is describing.
20
35
 
21
- Uploads local state, diffs against published revision,
22
- and shows a simulation of the effect.
36
+ Always exits 0, findings included: preflight is advisory and never gates a
37
+ publish. Use `plugsync validate` when you want errors to fail a pipeline.
23
38
 
24
39
  Examples:
25
40
  plugsync preview
26
41
  plugsync preview ./hubspot-juve
27
42
  """
28
- try:
29
- client = PlugSyncClient()
30
- except RuntimeError as e:
31
- console.print(f"[red]{e}[/red]")
32
- raise SystemExit(1)
43
+ client = build_client()
44
+ context = load_context(path)
33
45
 
34
- connector_dir = Path(path) if path else Path.cwd()
35
- if not (connector_dir / "plugsync.yaml").exists():
36
- console.print("[red]No plugsync.yaml found.[/red]")
37
- raise SystemExit(1)
38
-
39
- local_config = connector_dir / ".plugsync.yaml"
40
- if not local_config.exists():
41
- console.print("[red]No .plugsync.yaml found. Run 'plugsync pull' first.[/red]")
42
- raise SystemExit(1)
43
-
44
- with open(local_config) as f:
45
- data = yaml.safe_load(f) or {}
46
- connector_id = data.get("connector_id")
47
-
48
- # Upload current state (required for server-side preview)
49
- zip_data = read_directory_to_zip(connector_dir)
50
- click.confirm(
51
- "This will upload your local files to the remote working state. Continue?",
52
- abort=True,
46
+ warn_if_local_differs_from_draft(
47
+ context.config(), client.get_draft(context.connector_id)["config"]
53
48
  )
54
- client.import_zip(connector_id, zip_data)
55
-
56
- # Preview
57
- result = client.preview(connector_id)
58
-
59
- changes = result.get("changes", [])
60
- warnings = result.get("warnings", [])
61
- simulation = result.get("simulation")
62
- summary = result.get("summary", "0 changes")
63
49
 
50
+ result = client.preview(context.connector_id)
51
+ changes = result.get("changes") or []
64
52
  if not changes:
65
- console.print("[green]No changes to publish.[/green]")
66
- return
67
-
68
- # Changes table
69
- console.print(f"\n[bold]Preview: {summary}[/bold]\n")
70
-
71
- table = Table(show_header=True, header_style="bold")
72
- table.add_column("Change", width=20)
73
- table.add_column("Detail")
74
-
75
- for change in changes:
76
- change_type = change.get("type", "unknown")
77
- detail = change.get("detail", "")
78
-
79
- if "added" in change_type:
80
- prefix = "[green]+[/green]"
81
- elif "removed" in change_type:
82
- prefix = "[red]-[/red]"
83
- elif "modified" in change_type:
84
- prefix = "[yellow]~[/yellow]"
85
- else:
86
- prefix = " "
87
-
88
- table.add_row(f"{prefix} {change_type}", detail)
89
-
90
- console.print(table)
91
-
92
- # Simulation
93
- if simulation:
94
- import json
95
- console.print(Panel(
96
- json.dumps(simulation, indent=2),
97
- title="Simulation",
98
- border_style="blue",
99
- ))
100
-
101
- # Warnings
102
- if warnings:
103
- console.print("\n[yellow]Warnings:[/yellow]")
104
- for w in warnings:
105
- console.print(f" [yellow]! {w.get('detail', '')}[/yellow]")
106
-
107
- console.print(f"\nRun [bold]plugsync push[/bold] to publish these changes.")
53
+ console.print("[green]No changes to publish: the draft matches the active version.[/green]")
54
+ else:
55
+ console.print(f"\n[bold]Publishing would apply {len(changes)} change(s)[/bold]\n")
56
+ table = Table(show_header=True, header_style="bold")
57
+ table.add_column("Change", width=24)
58
+ table.add_column("Detail")
59
+ for change in changes:
60
+ scope = change.get("entity") or change.get("event_flow") or ""
61
+ detail = change.get("detail", "")
62
+ table.add_row(_mark(change.get("type", "unknown")), f"[bold]{scope}[/bold] {detail}" if scope else detail)
63
+ console.print(table)
64
+
65
+ render_preflight(console, client.preflight(context.connector_id))
66
+
67
+ if changes:
68
+ console.print("\nRun [bold]plugsync push --publish[/bold] to apply them.")