plugsync-cli 0.1.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/__init__.py +2 -0
- plugsync_cli/bundler.py +77 -0
- plugsync_cli/client.py +201 -0
- plugsync_cli/commands/__init__.py +1 -0
- plugsync_cli/commands/auth.py +156 -0
- plugsync_cli/commands/diff.py +169 -0
- plugsync_cli/commands/log.py +104 -0
- plugsync_cli/commands/plugin.py +470 -0
- plugsync_cli/commands/preview.py +107 -0
- plugsync_cli/commands/pull.py +121 -0
- plugsync_cli/commands/push.py +101 -0
- plugsync_cli/commands/rollback.py +72 -0
- plugsync_cli/commands/validate.py +66 -0
- plugsync_cli/compat.py +73 -0
- plugsync_cli/config.py +51 -0
- plugsync_cli/main.py +84 -0
- plugsync_cli/serializer.py +205 -0
- plugsync_cli-0.1.0.dist-info/METADATA +93 -0
- plugsync_cli-0.1.0.dist-info/RECORD +22 -0
- plugsync_cli-0.1.0.dist-info/WHEEL +5 -0
- plugsync_cli-0.1.0.dist-info/entry_points.txt +2 -0
- plugsync_cli-0.1.0.dist-info/top_level.txt +1 -0
plugsync_cli/__init__.py
ADDED
plugsync_cli/bundler.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bundle a TypeScript plugin directory into a single JS bundle via esbuild.
|
|
2
|
+
|
|
3
|
+
esbuild must be available on PATH (installed via `npm install -g esbuild` or
|
|
4
|
+
as a devDependency in the plugin directory).
|
|
5
|
+
|
|
6
|
+
Returns (bundle_bytes, sha256_hexdigest).
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def bundle_plugin(src_dir: str | Path) -> tuple[bytes, str]:
|
|
16
|
+
"""Bundle the TypeScript entrypoint in *src_dir* using esbuild.
|
|
17
|
+
|
|
18
|
+
Expects ``src_dir/src/index.ts`` as the entrypoint. The bundle is
|
|
19
|
+
written to a temporary file, read back as bytes, and its SHA-256 digest
|
|
20
|
+
is computed before returning.
|
|
21
|
+
|
|
22
|
+
Raises ``RuntimeError`` when esbuild fails or is not found.
|
|
23
|
+
"""
|
|
24
|
+
src_dir = Path(src_dir)
|
|
25
|
+
entrypoint = src_dir / "src" / "index.ts"
|
|
26
|
+
if not entrypoint.exists():
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"Entrypoint not found: {entrypoint}. "
|
|
29
|
+
"Expected src/index.ts in the plugin directory."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
with tempfile.NamedTemporaryFile(suffix=".js", delete=False) as tmp:
|
|
33
|
+
out_path = tmp.name
|
|
34
|
+
|
|
35
|
+
cmd = [
|
|
36
|
+
"esbuild",
|
|
37
|
+
str(entrypoint),
|
|
38
|
+
"--bundle",
|
|
39
|
+
"--platform=node",
|
|
40
|
+
"--target=node18",
|
|
41
|
+
"--format=cjs",
|
|
42
|
+
f"--outfile={out_path}",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
proc = subprocess.run(
|
|
47
|
+
cmd,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"esbuild not found. Install it with: npm install -g esbuild"
|
|
55
|
+
) from None
|
|
56
|
+
|
|
57
|
+
if proc.returncode != 0:
|
|
58
|
+
try:
|
|
59
|
+
os.unlink(out_path)
|
|
60
|
+
except OSError:
|
|
61
|
+
# Best-effort temp file cleanup; ignore errors so the real error is raised
|
|
62
|
+
pass
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
f"esbuild failed (exit {proc.returncode}):\n{proc.stderr}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
bundle_bytes = Path(out_path).read_bytes()
|
|
69
|
+
digest = hashlib.sha256(bundle_bytes).hexdigest()
|
|
70
|
+
finally:
|
|
71
|
+
try:
|
|
72
|
+
os.unlink(out_path)
|
|
73
|
+
except OSError:
|
|
74
|
+
# Best-effort temp file cleanup on success or read failure; ignore errors
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return bundle_bytes, digest
|
plugsync_cli/client.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""HTTP client wrapper for PluSync REST API."""
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from plugsync_cli.compat import check_api_compat
|
|
5
|
+
from plugsync_cli.config import get_api_key, get_api_url
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlugSyncClient:
|
|
9
|
+
"""Thin wrapper around httpx for PluSync API calls."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, api_url: str | None = None, api_key: str | None = None):
|
|
12
|
+
self.api_url = api_url or get_api_url()
|
|
13
|
+
self.api_key = api_key or get_api_key()
|
|
14
|
+
if not self.api_key:
|
|
15
|
+
raise RuntimeError(
|
|
16
|
+
"No API key configured. Run 'plugsync config set api_key <key>' "
|
|
17
|
+
"or set PLUGSYNC_API_KEY environment variable."
|
|
18
|
+
)
|
|
19
|
+
try:
|
|
20
|
+
check_api_compat(self.api_url)
|
|
21
|
+
except Exception:
|
|
22
|
+
# Compat check is advisory only (issue #957): never let it
|
|
23
|
+
# block a command from running, even if it misbehaves.
|
|
24
|
+
pass
|
|
25
|
+
self.headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
26
|
+
|
|
27
|
+
def _url(self, path: str) -> str:
|
|
28
|
+
return f"{self.api_url}/api{path}"
|
|
29
|
+
|
|
30
|
+
def _client(self) -> httpx.Client:
|
|
31
|
+
return httpx.Client(headers=self.headers, timeout=60.0)
|
|
32
|
+
|
|
33
|
+
def list_connectors(self) -> list[dict]:
|
|
34
|
+
with self._client() as c:
|
|
35
|
+
r = c.get(self._url("/connectors"))
|
|
36
|
+
r.raise_for_status()
|
|
37
|
+
return r.json()
|
|
38
|
+
|
|
39
|
+
def find_connector(self, name: str) -> dict | None:
|
|
40
|
+
connectors = self.list_connectors()
|
|
41
|
+
for conn in connectors:
|
|
42
|
+
if conn["name"] == name:
|
|
43
|
+
return conn
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def get_connector(self, connector_id: str) -> dict:
|
|
47
|
+
with self._client() as c:
|
|
48
|
+
r = c.get(self._url(f"/connectors/{connector_id}"))
|
|
49
|
+
r.raise_for_status()
|
|
50
|
+
return r.json()
|
|
51
|
+
|
|
52
|
+
def export_working_state(self, connector_id: str) -> bytes:
|
|
53
|
+
with self._client() as c:
|
|
54
|
+
r = c.get(self._url(f"/connectors/{connector_id}/export"))
|
|
55
|
+
r.raise_for_status()
|
|
56
|
+
return r.content
|
|
57
|
+
|
|
58
|
+
def export_revision(self, connector_id: str, version: int) -> bytes:
|
|
59
|
+
with self._client() as c:
|
|
60
|
+
r = c.get(self._url(f"/connectors/{connector_id}/revisions/{version}/export"))
|
|
61
|
+
r.raise_for_status()
|
|
62
|
+
return r.content
|
|
63
|
+
|
|
64
|
+
def import_zip(self, connector_id: str, zip_data: bytes) -> dict:
|
|
65
|
+
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
|
+
)
|
|
70
|
+
r.raise_for_status()
|
|
71
|
+
return r.json()
|
|
72
|
+
|
|
73
|
+
def validate(self, connector_id: str) -> dict:
|
|
74
|
+
with self._client() as c:
|
|
75
|
+
r = c.post(self._url(f"/connectors/{connector_id}/validate"))
|
|
76
|
+
r.raise_for_status()
|
|
77
|
+
return r.json()
|
|
78
|
+
|
|
79
|
+
def preview(self, connector_id: str) -> dict:
|
|
80
|
+
with self._client() as c:
|
|
81
|
+
r = c.post(self._url(f"/connectors/{connector_id}/preview"))
|
|
82
|
+
r.raise_for_status()
|
|
83
|
+
return r.json()
|
|
84
|
+
|
|
85
|
+
def publish(self, connector_id: str, change_summary: str | None = None) -> dict:
|
|
86
|
+
with self._client() as c:
|
|
87
|
+
body = {}
|
|
88
|
+
if change_summary:
|
|
89
|
+
body["change_summary"] = change_summary
|
|
90
|
+
r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
|
|
91
|
+
r.raise_for_status()
|
|
92
|
+
return r.json()
|
|
93
|
+
|
|
94
|
+
def list_revisions(self, connector_id: str) -> list[dict]:
|
|
95
|
+
with self._client() as c:
|
|
96
|
+
r = c.get(self._url(f"/connectors/{connector_id}/revisions"))
|
|
97
|
+
r.raise_for_status()
|
|
98
|
+
return r.json()
|
|
99
|
+
|
|
100
|
+
def restore_revision(self, connector_id: str, version: int) -> dict:
|
|
101
|
+
with self._client() as c:
|
|
102
|
+
r = c.post(self._url(f"/connectors/{connector_id}/revisions/{version}/restore"))
|
|
103
|
+
r.raise_for_status()
|
|
104
|
+
return r.json()
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Plugin API (T10, issue #190)
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def plugin_create(self, name: str, capabilities: list[str] | None = None) -> dict:
|
|
111
|
+
"""Create a new plugin (status=draft)."""
|
|
112
|
+
body: dict = {"name": name}
|
|
113
|
+
if capabilities is not None:
|
|
114
|
+
body["capabilities"] = capabilities
|
|
115
|
+
with self._client() as c:
|
|
116
|
+
r = c.post(self._url("/plugins"), json=body)
|
|
117
|
+
r.raise_for_status()
|
|
118
|
+
return r.json()
|
|
119
|
+
|
|
120
|
+
def plugin_list(self) -> list[dict]:
|
|
121
|
+
"""List all plugins for the current org."""
|
|
122
|
+
with self._client() as c:
|
|
123
|
+
r = c.get(self._url("/plugins"))
|
|
124
|
+
r.raise_for_status()
|
|
125
|
+
return r.json()
|
|
126
|
+
|
|
127
|
+
def plugin_get(self, plugin_id: str) -> dict:
|
|
128
|
+
"""Get a single plugin by ID."""
|
|
129
|
+
with self._client() as c:
|
|
130
|
+
r = c.get(self._url(f"/plugins/{plugin_id}"))
|
|
131
|
+
r.raise_for_status()
|
|
132
|
+
return r.json()
|
|
133
|
+
|
|
134
|
+
def plugin_push(self, plugin_id: str, bundle_bytes: bytes, source_code: str) -> dict:
|
|
135
|
+
"""Upload a new bundle + source_code for a plugin (multipart/form-data)."""
|
|
136
|
+
with self._client() as c:
|
|
137
|
+
r = c.post(
|
|
138
|
+
self._url(f"/plugins/{plugin_id}/push"),
|
|
139
|
+
files={"bundle": ("plugin.js", bundle_bytes, "application/javascript")},
|
|
140
|
+
data={"source_code": source_code},
|
|
141
|
+
)
|
|
142
|
+
r.raise_for_status()
|
|
143
|
+
return r.json()
|
|
144
|
+
|
|
145
|
+
def plugin_promote(self, plugin_id: str, target: str) -> dict:
|
|
146
|
+
"""Promote a plugin to staging or live."""
|
|
147
|
+
with self._client() as c:
|
|
148
|
+
r = c.post(
|
|
149
|
+
self._url(f"/plugins/{plugin_id}/promote"),
|
|
150
|
+
json={"target": target},
|
|
151
|
+
)
|
|
152
|
+
r.raise_for_status()
|
|
153
|
+
return r.json()
|
|
154
|
+
|
|
155
|
+
def plugin_invoke(self, plugin_id: str, payload: dict) -> dict:
|
|
156
|
+
"""Proxy an invocation to the plugin's Lambda function."""
|
|
157
|
+
with self._client() as c:
|
|
158
|
+
r = c.post(
|
|
159
|
+
self._url(f"/plugins/{plugin_id}/invoke"),
|
|
160
|
+
json={"payload": payload},
|
|
161
|
+
)
|
|
162
|
+
r.raise_for_status()
|
|
163
|
+
return r.json()
|
|
164
|
+
|
|
165
|
+
def plugin_logs(
|
|
166
|
+
self,
|
|
167
|
+
plugin_id: str,
|
|
168
|
+
connector_id: str | None = None,
|
|
169
|
+
since_minutes: int | None = None,
|
|
170
|
+
limit: int | None = None,
|
|
171
|
+
) -> dict:
|
|
172
|
+
"""Tail the most recent CloudWatch log lines for a plugin.
|
|
173
|
+
|
|
174
|
+
Optional filters (#293): connector_id narrows to one connector's
|
|
175
|
+
lines, since_minutes widens the lookback, limit caps the line count.
|
|
176
|
+
"""
|
|
177
|
+
params: dict = {}
|
|
178
|
+
if connector_id is not None:
|
|
179
|
+
params["connector_id"] = connector_id
|
|
180
|
+
if since_minutes is not None:
|
|
181
|
+
params["since_minutes"] = since_minutes
|
|
182
|
+
if limit is not None:
|
|
183
|
+
params["limit"] = limit
|
|
184
|
+
with self._client() as c:
|
|
185
|
+
r = c.get(self._url(f"/plugins/{plugin_id}/logs"), params=params)
|
|
186
|
+
r.raise_for_status()
|
|
187
|
+
return r.json()
|
|
188
|
+
|
|
189
|
+
def plugin_metrics(self, plugin_id: str, window: str = "24h") -> dict:
|
|
190
|
+
"""CloudWatch metrics (invocations, errors, latency) for a plugin (#293)."""
|
|
191
|
+
with self._client() as c:
|
|
192
|
+
r = c.get(
|
|
193
|
+
self._url(f"/plugins/{plugin_id}/metrics"),
|
|
194
|
+
params={"window": window},
|
|
195
|
+
)
|
|
196
|
+
r.raise_for_status()
|
|
197
|
+
return r.json()
|
|
198
|
+
|
|
199
|
+
def plugin_status(self, plugin_id: str) -> dict:
|
|
200
|
+
"""Get the current status of a plugin."""
|
|
201
|
+
return self.plugin_get(plugin_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""plugsync auth -- authentication helpers (T10, issue #190; issue #725).
|
|
2
|
+
|
|
3
|
+
Two ways to authenticate:
|
|
4
|
+
|
|
5
|
+
- Email + password login (``plugsync auth login``): exchanges credentials
|
|
6
|
+
for a short-TTL JWT (15 min), saved as the bearer credential (api_key) in
|
|
7
|
+
~/.plugsync/config.yaml. Fine for interactive use; a long-running script
|
|
8
|
+
will eventually hit a 401 once the JWT lapses (see the refresh recipe in
|
|
9
|
+
the API-first quickstart for that case).
|
|
10
|
+
- Org API key (``plugsync auth login --token ps_org_...``): saves an
|
|
11
|
+
existing org-level API key as-is, no login round-trip. This is the
|
|
12
|
+
recommended path for CI/scripts -- an org key has no forced 15-minute
|
|
13
|
+
expiry (only an optional ``expires_at`` set at creation time), so a
|
|
14
|
+
session built on it survives for hours without any refresh dance.
|
|
15
|
+
|
|
16
|
+
``PLUGSYNC_API_KEY`` overrides whatever is saved in config.yaml for either
|
|
17
|
+
credential type, without touching the file on disk -- handy for CI secrets.
|
|
18
|
+
|
|
19
|
+
OAuth device-flow is a post-Phase-A follow-up.
|
|
20
|
+
"""
|
|
21
|
+
import httpx
|
|
22
|
+
import click
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from plugsync_cli.config import get_api_url, get_config, save_config
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _save_api_key(value: str) -> None:
|
|
31
|
+
"""Persist ``value`` as the bearer credential in ~/.plugsync/config.yaml."""
|
|
32
|
+
cfg = get_config()
|
|
33
|
+
cfg["api_key"] = value
|
|
34
|
+
save_config(cfg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group("auth")
|
|
38
|
+
def auth():
|
|
39
|
+
"""Authenticate with plugsync."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@auth.command("login")
|
|
44
|
+
@click.option("--email", "-e", default=None, help="Email address")
|
|
45
|
+
@click.option("--password", "-p", default=None, help="Password", hide_input=True)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--token",
|
|
48
|
+
"-t",
|
|
49
|
+
default=None,
|
|
50
|
+
help="Org API key (ps_org_...) to save directly, skipping email/password login. "
|
|
51
|
+
"Recommended for CI/scripts (issue #725): no forced 15-minute expiry.",
|
|
52
|
+
)
|
|
53
|
+
def auth_login(email: str | None, password: str | None, token: str | None):
|
|
54
|
+
"""Log in and save credentials for subsequent commands.
|
|
55
|
+
|
|
56
|
+
Two modes:
|
|
57
|
+
|
|
58
|
+
\b
|
|
59
|
+
- `plugsync auth login --token ps_org_...`: saves an existing org API
|
|
60
|
+
key as-is. No network call, no email/password prompt.
|
|
61
|
+
- `plugsync auth login` (or with -e/-p): prompts for email and password
|
|
62
|
+
if not supplied, exchanges them for a short-TTL JWT, and saves that.
|
|
63
|
+
|
|
64
|
+
The credential is saved under api_key in ~/.plugsync/config.yaml.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
plugsync auth login --token ps_org_abc123...
|
|
68
|
+
plugsync auth login
|
|
69
|
+
plugsync auth login -e you@example.com
|
|
70
|
+
"""
|
|
71
|
+
if token:
|
|
72
|
+
if not token.startswith("ps_org_"):
|
|
73
|
+
console.print(
|
|
74
|
+
"[yellow]Warning: this doesn't look like an org API key "
|
|
75
|
+
"(expected prefix 'ps_org_'). Saving it anyway.[/yellow]"
|
|
76
|
+
)
|
|
77
|
+
_save_api_key(token)
|
|
78
|
+
console.print("[green]Org API key saved to ~/.plugsync/config.yaml[/green]")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if not email:
|
|
82
|
+
email = click.prompt("Email")
|
|
83
|
+
if not password:
|
|
84
|
+
password = click.prompt("Password", hide_input=True)
|
|
85
|
+
|
|
86
|
+
api_url = get_api_url()
|
|
87
|
+
login_url = f"{api_url}/api/auth/login"
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
response = httpx.post(
|
|
91
|
+
login_url,
|
|
92
|
+
json={"email": email, "password": password},
|
|
93
|
+
timeout=30.0,
|
|
94
|
+
)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
except httpx.HTTPStatusError as exc:
|
|
97
|
+
status = exc.response.status_code
|
|
98
|
+
try:
|
|
99
|
+
detail = exc.response.json().get("detail", "")
|
|
100
|
+
except Exception:
|
|
101
|
+
detail = str(exc)
|
|
102
|
+
|
|
103
|
+
if status == 401:
|
|
104
|
+
msg = "Invalid credentials."
|
|
105
|
+
if isinstance(detail, dict):
|
|
106
|
+
err = detail.get("error", "")
|
|
107
|
+
if err == "account_deactivated":
|
|
108
|
+
msg = "Account deactivated. Contact support."
|
|
109
|
+
console.print(f"[red]Login failed: {msg}[/red]")
|
|
110
|
+
elif status == 403:
|
|
111
|
+
msg = "Access denied."
|
|
112
|
+
if isinstance(detail, dict):
|
|
113
|
+
err = detail.get("error", "")
|
|
114
|
+
if err == "email_not_verified":
|
|
115
|
+
msg = (
|
|
116
|
+
"Email not verified. "
|
|
117
|
+
"Check your inbox and click the verification link."
|
|
118
|
+
)
|
|
119
|
+
elif err == "no_active_organization":
|
|
120
|
+
msg = "No active organization found for this account."
|
|
121
|
+
console.print(f"[red]Login failed: {msg}[/red]")
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[red]Login error ({status}): {detail}[/red]")
|
|
124
|
+
|
|
125
|
+
raise SystemExit(1)
|
|
126
|
+
except httpx.RequestError as exc:
|
|
127
|
+
console.print(f"[red]Could not reach {api_url}: {exc}[/red]")
|
|
128
|
+
raise SystemExit(1)
|
|
129
|
+
|
|
130
|
+
data = response.json()
|
|
131
|
+
jwt_token = data.get("access_token")
|
|
132
|
+
if not jwt_token:
|
|
133
|
+
console.print("[red]Login succeeded but no access_token in response.[/red]")
|
|
134
|
+
raise SystemExit(1)
|
|
135
|
+
|
|
136
|
+
_save_api_key(jwt_token)
|
|
137
|
+
|
|
138
|
+
name = data.get("full_name") or data.get("email") or email
|
|
139
|
+
console.print(f"[green]Logged in as {name}[/green]")
|
|
140
|
+
console.print(" Credentials saved to ~/.plugsync/config.yaml")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@auth.command("logout")
|
|
144
|
+
def auth_logout():
|
|
145
|
+
"""Remove stored credentials from config.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
plugsync auth logout
|
|
149
|
+
"""
|
|
150
|
+
cfg = get_config()
|
|
151
|
+
if "api_key" in cfg:
|
|
152
|
+
del cfg["api_key"]
|
|
153
|
+
save_config(cfg)
|
|
154
|
+
console.print("[green]Logged out - credentials removed.[/green]")
|
|
155
|
+
else:
|
|
156
|
+
console.print("[yellow]No credentials stored.[/yellow]")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""plugsync diff — compare local files vs published revision (read-only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from plugsync_cli.client import PlugSyncClient
|
|
15
|
+
from plugsync_cli.serializer import read_directory_to_zip
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
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
|
+
|
|
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)
|
|
31
|
+
|
|
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
|
+
|
|
39
|
+
return connector_dir, connector_id
|
|
40
|
+
|
|
41
|
+
|
|
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] = {}
|
|
47
|
+
|
|
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()
|
|
59
|
+
|
|
60
|
+
return {"settings": settings, "entities": entities, "handlers": handlers}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _diff_states(local: dict, remote: dict) -> list[dict]:
|
|
64
|
+
"""Compare local and remote states, return list of changes."""
|
|
65
|
+
changes: list[dict] = []
|
|
66
|
+
|
|
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
|
+
|
|
93
|
+
return changes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@click.command()
|
|
97
|
+
@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.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
plugsync diff
|
|
106
|
+
plugsync diff ./hubspot-juve
|
|
107
|
+
"""
|
|
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
|
+
|
|
136
|
+
if not changes:
|
|
137
|
+
console.print("[green]No changes. Local files match published revision.[/green]")
|
|
138
|
+
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)
|