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 +119 -16
- plugsync_cli/commands/auth.py +4 -0
- plugsync_cli/commands/diff.py +90 -137
- plugsync_cli/commands/preview.py +47 -86
- plugsync_cli/commands/pull.py +43 -94
- plugsync_cli/commands/push.py +90 -80
- plugsync_cli/commands/rollback.py +7 -7
- plugsync_cli/commands/validate.py +40 -52
- plugsync_cli/compat.py +40 -17
- plugsync_cli/config.py +12 -2
- plugsync_cli/context.py +196 -0
- plugsync_cli/findings.py +119 -0
- plugsync_cli/main.py +46 -3
- plugsync_cli/serializer.py +212 -186
- {plugsync_cli-0.1.0.dist-info → plugsync_cli-0.3.0.dist-info}/METADATA +22 -16
- plugsync_cli-0.3.0.dist-info/RECORD +24 -0
- plugsync_cli-0.1.0.dist-info/RECORD +0 -22
- {plugsync_cli-0.1.0.dist-info → plugsync_cli-0.3.0.dist-info}/WHEEL +0 -0
- {plugsync_cli-0.1.0.dist-info → plugsync_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {plugsync_cli-0.1.0.dist-info → plugsync_cli-0.3.0.dist-info}/top_level.txt +0 -0
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
|
|
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}/
|
|
131
|
+
r = c.get(self._url(f"/connectors/{connector_id}/draft"))
|
|
55
132
|
r.raise_for_status()
|
|
56
|
-
return r.
|
|
133
|
+
return r.json()
|
|
57
134
|
|
|
58
|
-
def
|
|
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.
|
|
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.
|
|
155
|
+
return r.json()
|
|
63
156
|
|
|
64
|
-
def
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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}/
|
|
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}/
|
|
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
|
|
plugsync_cli/commands/auth.py
CHANGED
|
@@ -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()
|
plugsync_cli/commands/diff.py
CHANGED
|
@@ -1,169 +1,122 @@
|
|
|
1
|
-
"""plugsync diff
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
"""
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
elif name
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
53
|
+
return changes
|
|
61
54
|
|
|
62
55
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if not
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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)
|
plugsync_cli/commands/preview.py
CHANGED
|
@@ -1,107 +1,68 @@
|
|
|
1
|
-
"""plugsync preview
|
|
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.
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.")
|