plugsync-cli 0.1.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/PKG-INFO +93 -0
- plugsync_cli-0.1.0/README.md +62 -0
- plugsync_cli-0.1.0/plugsync_cli/__init__.py +2 -0
- plugsync_cli-0.1.0/plugsync_cli/bundler.py +77 -0
- plugsync_cli-0.1.0/plugsync_cli/client.py +201 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/__init__.py +1 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/auth.py +156 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/diff.py +169 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/log.py +104 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/plugin.py +470 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/preview.py +107 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/pull.py +121 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/push.py +101 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/rollback.py +72 -0
- plugsync_cli-0.1.0/plugsync_cli/commands/validate.py +66 -0
- plugsync_cli-0.1.0/plugsync_cli/compat.py +73 -0
- plugsync_cli-0.1.0/plugsync_cli/config.py +51 -0
- plugsync_cli-0.1.0/plugsync_cli/main.py +84 -0
- plugsync_cli-0.1.0/plugsync_cli/serializer.py +205 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/PKG-INFO +93 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/SOURCES.txt +31 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/dependency_links.txt +1 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/entry_points.txt +2 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/requires.txt +9 -0
- plugsync_cli-0.1.0/plugsync_cli.egg-info/top_level.txt +1 -0
- plugsync_cli-0.1.0/pyproject.toml +58 -0
- plugsync_cli-0.1.0/setup.cfg +4 -0
- plugsync_cli-0.1.0/tests/test_auth_commands.py +152 -0
- plugsync_cli-0.1.0/tests/test_client.py +36 -0
- plugsync_cli-0.1.0/tests/test_commands.py +339 -0
- plugsync_cli-0.1.0/tests/test_compat.py +137 -0
- plugsync_cli-0.1.0/tests/test_plugin_commands.py +444 -0
- plugsync_cli-0.1.0/tests/test_serializer.py +160 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plugsync-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
|
|
5
|
+
Author-email: Exelab <hello@plugsync.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://plugsync.com
|
|
8
|
+
Project-URL: Documentation, https://github.com/exelab/plugsync/blob/main/docs/content/reference/plugins/dev-loop.md
|
|
9
|
+
Project-URL: Repository, https://github.com/exelab/plugsync
|
|
10
|
+
Project-URL: Issues, https://github.com/exelab/plugsync/issues
|
|
11
|
+
Keywords: plugsync,hubspot,cli,connectors,integration
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: Other/Proprietary License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: click>=8.1
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Requires-Dist: rich>=13.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# plugsync-cli
|
|
33
|
+
|
|
34
|
+
Command-line client for [plugsync](https://plugsync.com), managing HubSpot
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install plugsync-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.12+.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
plugsync config set api_url https://your-plugsync-instance.example.com
|
|
51
|
+
plugsync auth login -e you@example.com
|
|
52
|
+
plugsync pull my-connector
|
|
53
|
+
```
|
|
54
|
+
|
|
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
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | Purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `plugsync auth login` / `logout` | Authenticate (email/password JWT, or a long-lived org API key with `--token`) |
|
|
64
|
+
| `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) |
|
|
73
|
+
|
|
74
|
+
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
75
|
+
|
|
76
|
+
## Compatibility
|
|
77
|
+
|
|
78
|
+
This CLI ships on its own release cadence, independent of the plugsync
|
|
79
|
+
backend. It declares the minimum API version it expects and checks it
|
|
80
|
+
against the API at the start of any command that talks to it, warning
|
|
81
|
+
(never failing outright) if the API is older than expected.
|
|
82
|
+
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
Full reference docs, including the plugin authoring dev-loop, live at
|
|
86
|
+
[docs.plugsync.com](https://plugsync.com) and in the
|
|
87
|
+
[plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
Proprietary. This CLI talks to the plugsync SaaS API; see
|
|
92
|
+
[plugsync.com/terms](https://plugsync.com/terms) for the terms governing use
|
|
93
|
+
of the plugsync service.
|
|
@@ -0,0 +1,62 @@
|
|
|
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, diff against the remote, validate, preview a publish, and
|
|
6
|
+
manage plugins -- all without cloning the plugsync backend repo.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install plugsync-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.12+.
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
plugsync config set api_url https://your-plugsync-instance.example.com
|
|
20
|
+
plugsync auth login -e you@example.com
|
|
21
|
+
plugsync pull my-connector
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`plugsync auth login` saves a bearer credential to `~/.plugsync/config.yaml`
|
|
25
|
+
that every subsequent command reuses. `PLUGSYNC_API_URL` / `PLUGSYNC_API_KEY`
|
|
26
|
+
environment variables override the config file, handy for CI.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
| Command | Purpose |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `plugsync auth login` / `logout` | Authenticate (email/password JWT, or a long-lived org API key with `--token`) |
|
|
33
|
+
| `plugsync config set/get/show` | Manage `~/.plugsync/config.yaml` |
|
|
34
|
+
| `plugsync pull <name>` | Download a connector as a local directory |
|
|
35
|
+
| `plugsync push <name>` | Upload a local connector directory |
|
|
36
|
+
| `plugsync diff <name>` | Diff local vs. remote working state |
|
|
37
|
+
| `plugsync validate <name>` | Validate a local connector config |
|
|
38
|
+
| `plugsync preview <name>` | Preview a publish's effects |
|
|
39
|
+
| `plugsync log <name>` | Show a connector's revision history |
|
|
40
|
+
| `plugsync rollback <name>` | Restore a previous revision |
|
|
41
|
+
| `plugsync plugin init/push/list/status/logs/invoke` | Author and manage plugins (Enterprise tier) |
|
|
42
|
+
|
|
43
|
+
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
44
|
+
|
|
45
|
+
## Compatibility
|
|
46
|
+
|
|
47
|
+
This CLI ships on its own release cadence, independent of the plugsync
|
|
48
|
+
backend. It declares the minimum API version it expects and checks it
|
|
49
|
+
against the API at the start of any command that talks to it, warning
|
|
50
|
+
(never failing outright) if the API is older than expected.
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
Full reference docs, including the plugin authoring dev-loop, live at
|
|
55
|
+
[docs.plugsync.com](https://plugsync.com) and in the
|
|
56
|
+
[plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
Proprietary. This CLI talks to the plugsync SaaS API; see
|
|
61
|
+
[plugsync.com/terms](https://plugsync.com/terms) for the terms governing use
|
|
62
|
+
of the plugsync service.
|
|
@@ -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
|
|
@@ -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]")
|