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.
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/PKG-INFO +22 -16
- plugsync_cli-0.3.0/README.md +68 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/client.py +119 -16
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/auth.py +4 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +122 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/preview.py +68 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/pull.py +70 -0
- plugsync_cli-0.3.0/plugsync_cli/commands/push.py +111 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/rollback.py +7 -7
- plugsync_cli-0.3.0/plugsync_cli/commands/validate.py +54 -0
- plugsync_cli-0.3.0/plugsync_cli/compat.py +96 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/config.py +12 -2
- plugsync_cli-0.3.0/plugsync_cli/context.py +196 -0
- plugsync_cli-0.3.0/plugsync_cli/findings.py +119 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/main.py +46 -3
- plugsync_cli-0.3.0/plugsync_cli/serializer.py +231 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/PKG-INFO +22 -16
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/SOURCES.txt +5 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/pyproject.toml +1 -1
- plugsync_cli-0.3.0/tests/test_api_contract.py +273 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_auth_commands.py +26 -0
- plugsync_cli-0.3.0/tests/test_commands.py +849 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_compat.py +25 -13
- plugsync_cli-0.3.0/tests/test_config.py +93 -0
- plugsync_cli-0.3.0/tests/test_e2e_draft_api.py +442 -0
- plugsync_cli-0.3.0/tests/test_serializer.py +239 -0
- plugsync_cli-0.1.0/README.md +0 -62
- plugsync_cli-0.1.0/plugsync_cli/commands/diff.py +0 -169
- plugsync_cli-0.1.0/plugsync_cli/commands/preview.py +0 -107
- plugsync_cli-0.1.0/plugsync_cli/commands/pull.py +0 -121
- plugsync_cli-0.1.0/plugsync_cli/commands/push.py +0 -101
- plugsync_cli-0.1.0/plugsync_cli/commands/validate.py +0 -66
- plugsync_cli-0.1.0/plugsync_cli/compat.py +0 -73
- plugsync_cli-0.1.0/plugsync_cli/serializer.py +0 -205
- plugsync_cli-0.1.0/tests/test_commands.py +0 -339
- plugsync_cli-0.1.0/tests/test_serializer.py +0 -160
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/__init__.py +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/setup.cfg +0 -0
- {plugsync_cli-0.1.0 → plugsync_cli-0.3.0}/tests/test_client.py +0 -0
- {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.
|
|
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
|
|
37
|
-
manage plugins -- all without cloning the plugsync
|
|
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
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
68
|
-
| `plugsync validate <name>` |
|
|
69
|
-
| `plugsync preview <name>` | Preview a publish's effects |
|
|
70
|
-
| `plugsync log <name>` | Show a connector's
|
|
71
|
-
| `plugsync rollback <name>` | Restore a
|
|
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
|
|
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
|
|
|
@@ -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
|
+
)
|