plugsync-cli 0.1.0__tar.gz → 0.3.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 (48) hide show
  1. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/PKG-INFO +22 -16
  2. plugsync_cli-0.3.0/README.md +68 -0
  3. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/client.py +119 -16
  4. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/auth.py +4 -0
  5. plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +122 -0
  6. plugsync_cli-0.3.0/plugsync_cli/commands/preview.py +68 -0
  7. plugsync_cli-0.3.0/plugsync_cli/commands/pull.py +70 -0
  8. plugsync_cli-0.3.0/plugsync_cli/commands/push.py +111 -0
  9. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/rollback.py +7 -7
  10. plugsync_cli-0.3.0/plugsync_cli/commands/validate.py +54 -0
  11. plugsync_cli-0.3.0/plugsync_cli/compat.py +96 -0
  12. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/config.py +12 -2
  13. plugsync_cli-0.3.0/plugsync_cli/context.py +196 -0
  14. plugsync_cli-0.3.0/plugsync_cli/findings.py +119 -0
  15. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/main.py +46 -3
  16. plugsync_cli-0.3.0/plugsync_cli/serializer.py +231 -0
  17. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/PKG-INFO +22 -16
  18. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/SOURCES.txt +5 -0
  19. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/pyproject.toml +1 -1
  20. plugsync_cli-0.3.0/tests/test_api_contract.py +273 -0
  21. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_auth_commands.py +26 -0
  22. plugsync_cli-0.3.0/tests/test_commands.py +849 -0
  23. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_compat.py +25 -13
  24. plugsync_cli-0.3.0/tests/test_config.py +93 -0
  25. plugsync_cli-0.3.0/tests/test_e2e_draft_api.py +442 -0
  26. plugsync_cli-0.3.0/tests/test_serializer.py +239 -0
  27. plugsync_cli-0.1.0/README.md +0 -62
  28. plugsync_cli-0.1.0/plugsync_cli/commands/diff.py +0 -169
  29. plugsync_cli-0.1.0/plugsync_cli/commands/preview.py +0 -107
  30. plugsync_cli-0.1.0/plugsync_cli/commands/pull.py +0 -121
  31. plugsync_cli-0.1.0/plugsync_cli/commands/push.py +0 -101
  32. plugsync_cli-0.1.0/plugsync_cli/commands/validate.py +0 -66
  33. plugsync_cli-0.1.0/plugsync_cli/compat.py +0 -73
  34. plugsync_cli-0.1.0/plugsync_cli/serializer.py +0 -205
  35. plugsync_cli-0.1.0/tests/test_commands.py +0 -339
  36. plugsync_cli-0.1.0/tests/test_serializer.py +0 -160
  37. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/__init__.py +0 -0
  38. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/bundler.py +0 -0
  39. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/__init__.py +0 -0
  40. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/log.py +0 -0
  41. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/plugin.py +0 -0
  42. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  43. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  44. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/requires.txt +0 -0
  45. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  46. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/setup.cfg +0 -0
  47. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_client.py +0 -0
  48. {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_plugin_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -33,8 +33,12 @@ Requires-Dist: respx>=0.21; extra == "dev"
33
33
 
34
34
  Command-line client for [plugsync](https://plugsync.com), managing HubSpot
35
35
  connectors like code: pull a connector's config to a local directory, edit
36
- it, push it back, diff against the remote, validate, preview a publish, and
37
- manage plugins -- all without cloning the plugsync backend repo.
36
+ it, push it back to the draft, diff against a published version, validate,
37
+ preview a publish, and manage plugins -- all without cloning the plugsync
38
+ backend repo.
39
+
40
+ `push` writes the draft and stops. Publishing is opt-in (`push --publish`),
41
+ so a config never reaches production as a side effect of saving a file.
38
42
 
39
43
  ## Install
40
44
 
@@ -47,14 +51,16 @@ Requires Python 3.12+.
47
51
  ## Quickstart
48
52
 
49
53
  ```bash
50
- plugsync config set api_url https://your-plugsync-instance.example.com
51
54
  plugsync auth login -e you@example.com
52
55
  plugsync pull my-connector
53
56
  ```
54
57
 
55
- `plugsync auth login` saves a bearer credential to `~/.plugsync/config.yaml`
56
- that every subsequent command reuses. `PLUGSYNC_API_URL` / `PLUGSYNC_API_KEY`
57
- environment variables override the config file, handy for CI.
58
+ `api_url` defaults to the hosted platform (`https://app.plugsync.com`), so
59
+ there's nothing to configure for the common case. Point it elsewhere with
60
+ `plugsync config set api_url <url>`. `plugsync auth login` saves a bearer
61
+ credential to `~/.plugsync/config.yaml` that every subsequent command
62
+ reuses. `PLUGSYNC_API_URL` / `PLUGSYNC_API_KEY` environment variables
63
+ override the config file, handy for CI.
58
64
 
59
65
  ## Commands
60
66
 
@@ -62,14 +68,14 @@ environment variables override the config file, handy for CI.
62
68
  |---|---|
63
69
  | `plugsync auth login` / `logout` | Authenticate (email/password JWT, or a long-lived org API key with `--token`) |
64
70
  | `plugsync config set/get/show` | Manage `~/.plugsync/config.yaml` |
65
- | `plugsync pull <name>` | Download a connector as a local directory |
66
- | `plugsync push <name>` | Upload a local connector directory |
67
- | `plugsync diff <name>` | Diff local vs. remote working state |
68
- | `plugsync validate <name>` | Validate a local connector config |
69
- | `plugsync preview <name>` | Preview a publish's effects |
70
- | `plugsync log <name>` | Show a connector's revision history |
71
- | `plugsync rollback <name>` | Restore a previous revision |
72
- | `plugsync plugin init/push/list/status/logs/invoke` | Author and manage plugins (Enterprise tier) |
71
+ | `plugsync pull <name>` | Download a connector's draft as a local directory (`--revision N` for a published version) |
72
+ | `plugsync push <name>` | Upload a local connector directory to the draft (`--publish` to also publish) |
73
+ | `plugsync diff <name>` | Diff local files vs. a published version |
74
+ | `plugsync validate <name>` | Preflight the draft; non-zero exit on an error |
75
+ | `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
76
+ | `plugsync log <name>` | Show a connector's published version history |
77
+ | `plugsync rollback <name>` | Restore a published version into the draft |
78
+ | `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
73
79
 
74
80
  Run `plugsync --help` or `plugsync <command> --help` for the full option list.
75
81
 
@@ -83,7 +89,7 @@ against the API at the start of any command that talks to it, warning
83
89
  ## Documentation
84
90
 
85
91
  Full reference docs, including the plugin authoring dev-loop, live at
86
- [docs.plugsync.com](https://plugsync.com) and in the
92
+ [docs.plugsync.com](https://docs.plugsync.com) and in the
87
93
  [plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
88
94
 
89
95
  ## License
@@ -0,0 +1,68 @@
1
+ # plugsync-cli
2
+
3
+ Command-line client for [plugsync](https://plugsync.com), managing HubSpot
4
+ connectors like code: pull a connector's config to a local directory, edit
5
+ it, push it back to the draft, diff against a published version, validate,
6
+ preview a publish, and manage plugins -- all without cloning the plugsync
7
+ backend repo.
8
+
9
+ `push` writes the draft and stops. Publishing is opt-in (`push --publish`),
10
+ so a config never reaches production as a side effect of saving a file.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install plugsync-cli
16
+ ```
17
+
18
+ Requires Python 3.12+.
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ plugsync auth login -e you@example.com
24
+ plugsync pull my-connector
25
+ ```
26
+
27
+ `api_url` defaults to the hosted platform (`https://app.plugsync.com`), so
28
+ there's nothing to configure for the common case. Point it elsewhere with
29
+ `plugsync config set api_url <url>`. `plugsync auth login` saves a bearer
30
+ credential to `~/.plugsync/config.yaml` that every subsequent command
31
+ reuses. `PLUGSYNC_API_URL` / `PLUGSYNC_API_KEY` environment variables
32
+ override the config file, handy for CI.
33
+
34
+ ## Commands
35
+
36
+ | Command | Purpose |
37
+ |---|---|
38
+ | `plugsync auth login` / `logout` | Authenticate (email/password JWT, or a long-lived org API key with `--token`) |
39
+ | `plugsync config set/get/show` | Manage `~/.plugsync/config.yaml` |
40
+ | `plugsync pull <name>` | Download a connector's draft as a local directory (`--revision N` for a published version) |
41
+ | `plugsync push <name>` | Upload a local connector directory to the draft (`--publish` to also publish) |
42
+ | `plugsync diff <name>` | Diff local files vs. a published version |
43
+ | `plugsync validate <name>` | Preflight the draft; non-zero exit on an error |
44
+ | `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
45
+ | `plugsync log <name>` | Show a connector's published version history |
46
+ | `plugsync rollback <name>` | Restore a published version into the draft |
47
+ | `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
48
+
49
+ Run `plugsync --help` or `plugsync <command> --help` for the full option list.
50
+
51
+ ## Compatibility
52
+
53
+ This CLI ships on its own release cadence, independent of the plugsync
54
+ backend. It declares the minimum API version it expects and checks it
55
+ against the API at the start of any command that talks to it, warning
56
+ (never failing outright) if the API is older than expected.
57
+
58
+ ## Documentation
59
+
60
+ Full reference docs, including the plugin authoring dev-loop, live at
61
+ [docs.plugsync.com](https://docs.plugsync.com) and in the
62
+ [plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
63
+
64
+ ## License
65
+
66
+ Proprietary. This CLI talks to the plugsync SaaS API; see
67
+ [plugsync.com/terms](https://plugsync.com/terms) for the terms governing use
68
+ of the plugsync service.
@@ -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()
@@ -0,0 +1,122 @@
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
+ import json
13
+
14
+ import click
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from plugsync_cli.context import build_client, fail, load_context
19
+
20
+ console = Console()
21
+
22
+ # Top-level config keys compared item-by-item (keyed by name) rather than whole.
23
+ COLLECTIONS = {"entities": "entity", "flows": "flow"}
24
+
25
+
26
+ def _by_name(items) -> dict[str, dict]:
27
+ return {item["name"]: item for item in items or []}
28
+
29
+
30
+ def _differs(left, right) -> bool:
31
+ return json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True)
32
+
33
+
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] = []
37
+
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"})
52
+
53
+ return changes
54
+
55
+
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
60
+
61
+
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)
78
+
79
+
80
+ @click.command()
81
+ @click.argument("path", required=False, default=None)
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.
94
+
95
+ Examples:
96
+ plugsync diff
97
+ plugsync diff ./hubspot-juve
98
+ plugsync diff --revision 3
99
+ """
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)
119
+ if not changes:
120
+ console.print(f"[green]No changes. Local files match published v{revision}.[/green]")
121
+ return
122
+ _render(changes)
@@ -0,0 +1,68 @@
1
+ """plugsync preview -- what publishing the draft would change."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
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
+
13
+ console = Console()
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
+
25
+
26
+ @click.command()
27
+ @click.argument("path", required=False, default=None)
28
+ def preview(path: str | None):
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.
35
+
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.
38
+
39
+ Examples:
40
+ plugsync preview
41
+ plugsync preview ./hubspot-juve
42
+ """
43
+ client = build_client()
44
+ context = load_context(path)
45
+
46
+ warn_if_local_differs_from_draft(
47
+ context.config(), client.get_draft(context.connector_id)["config"]
48
+ )
49
+
50
+ result = client.preview(context.connector_id)
51
+ changes = result.get("changes") or []
52
+ if not 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.")
@@ -0,0 +1,70 @@
1
+ """plugsync pull -- download a connector's config into a local directory."""
2
+ from pathlib import Path
3
+
4
+ import click
5
+ from rich.console import Console
6
+
7
+ from plugsync_cli.context import build_client, fail, warn_if_flow_order_is_not_preserved
8
+ from plugsync_cli.serializer import write_config_directory, write_local_state
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command()
14
+ @click.argument("connector_name")
15
+ @click.option(
16
+ "--revision",
17
+ "-r",
18
+ type=int,
19
+ default=None,
20
+ help="Pull a published version instead of the draft",
21
+ )
22
+ @click.option("--output", "-o", type=click.Path(), default=None, help="Output directory path")
23
+ def pull(connector_name: str, revision: int | None, output: str | None):
24
+ """Download a connector's config as a local directory.
25
+
26
+ Without --revision this reads the DRAFT (the editable config, what the
27
+ dashboard shows under "Review changes"). With --revision it reads a
28
+ published version, which is read-only history.
29
+
30
+ Examples:
31
+ plugsync pull hubspot-juve
32
+ plugsync pull hubspot-juve --revision 3
33
+ plugsync pull hubspot-juve -o ./my-dir
34
+ """
35
+ client = build_client()
36
+
37
+ connector = client.find_connector(connector_name)
38
+ if not connector:
39
+ fail(f"Connector '{connector_name}' not found")
40
+ connector_id = connector["id"]
41
+
42
+ state = {"connector": connector_name, "connector_id": connector_id}
43
+ if revision is not None:
44
+ console.print(f"Pulling v{revision} of [bold]{connector_name}[/bold]...")
45
+ payload = client.get_version(connector_id, revision)
46
+ state["source"] = f"version {revision}"
47
+ state["version"] = payload["version"]
48
+ else:
49
+ console.print(f"Pulling the draft of [bold]{connector_name}[/bold]...")
50
+ payload = client.get_draft(connector_id)
51
+ state["source"] = "draft"
52
+ # Recorded so `push` can send it as its expected_version: that is what
53
+ # turns a concurrent dashboard edit into a 409 instead of a silent
54
+ # overwrite of someone else's work.
55
+ state["draft_version"] = payload["draft_version"]
56
+
57
+ config = payload["config"]
58
+ output_dir = Path(output) if output else Path.cwd() / connector_name
59
+ write_config_directory(output_dir, config)
60
+ write_local_state(output_dir, state)
61
+
62
+ warn_if_flow_order_is_not_preserved(config)
63
+
64
+ entities = len(config.get("entities") or [])
65
+ flows = len(config.get("flows") or [])
66
+ console.print(f"[green]Pulled {state['source']} to {output_dir}/[/green]")
67
+ console.print(
68
+ f" {entities} entit{'y' if entities == 1 else 'ies'}, "
69
+ f"{flows} flow{'' if flows == 1 else 's'}"
70
+ )