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.
@@ -0,0 +1,2 @@
1
+ """PluSync CLI — git-like connector management."""
2
+ __version__ = "0.1.0"
@@ -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)