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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""plugsync log — show revision history."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from plugsync_cli.client import PlugSyncClient
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_relative_time(published_at: str) -> str:
|
|
15
|
+
"""Convert ISO timestamp to relative time string."""
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
dt = datetime.fromisoformat(published_at.replace("Z", "+00:00"))
|
|
20
|
+
now = datetime.now(timezone.utc)
|
|
21
|
+
delta = now - dt
|
|
22
|
+
|
|
23
|
+
if delta.days > 30:
|
|
24
|
+
return f"{delta.days // 30}mo ago"
|
|
25
|
+
if delta.days > 0:
|
|
26
|
+
return f"{delta.days}d ago"
|
|
27
|
+
hours = delta.seconds // 3600
|
|
28
|
+
if hours > 0:
|
|
29
|
+
return f"{hours}h ago"
|
|
30
|
+
minutes = delta.seconds // 60
|
|
31
|
+
if minutes > 0:
|
|
32
|
+
return f"{minutes}m ago"
|
|
33
|
+
return "just now"
|
|
34
|
+
except Exception:
|
|
35
|
+
return published_at
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command("log")
|
|
39
|
+
@click.argument("connector_name", required=False, default=None)
|
|
40
|
+
@click.option("--limit", "-n", type=int, default=20, help="Max revisions to show")
|
|
41
|
+
def log_cmd(connector_name: str | None, limit: int):
|
|
42
|
+
"""Show revision history.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
plugsync log # from cwd connector
|
|
46
|
+
plugsync log hubspot-juve # by name
|
|
47
|
+
plugsync log -n 5 # last 5 revisions
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
client = PlugSyncClient()
|
|
51
|
+
except RuntimeError as e:
|
|
52
|
+
console.print(f"[red]{e}[/red]")
|
|
53
|
+
raise SystemExit(1)
|
|
54
|
+
|
|
55
|
+
# Resolve connector_id
|
|
56
|
+
connector_id = None
|
|
57
|
+
|
|
58
|
+
if connector_name:
|
|
59
|
+
connector = client.find_connector(connector_name)
|
|
60
|
+
if not connector:
|
|
61
|
+
console.print(f"[red]Connector '{connector_name}' not found[/red]")
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
connector_id = connector["id"]
|
|
64
|
+
else:
|
|
65
|
+
# Try cwd
|
|
66
|
+
local_config = Path.cwd() / ".plugsync.yaml"
|
|
67
|
+
if local_config.exists():
|
|
68
|
+
with open(local_config) as f:
|
|
69
|
+
data = yaml.safe_load(f) or {}
|
|
70
|
+
connector_id = data.get("connector_id")
|
|
71
|
+
connector_name = data.get("connector")
|
|
72
|
+
|
|
73
|
+
if not connector_id:
|
|
74
|
+
console.print("[red]Specify a connector name or run from a connector directory.[/red]")
|
|
75
|
+
raise SystemExit(1)
|
|
76
|
+
|
|
77
|
+
revisions = client.list_revisions(connector_id)
|
|
78
|
+
|
|
79
|
+
if not revisions:
|
|
80
|
+
console.print(f"[yellow]No revisions published for {connector_name or connector_id}[/yellow]")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
84
|
+
table.add_column("Version", style="bold cyan", width=6)
|
|
85
|
+
table.add_column("Time", width=12)
|
|
86
|
+
table.add_column("By", width=12)
|
|
87
|
+
table.add_column("Summary")
|
|
88
|
+
|
|
89
|
+
for i, rev in enumerate(revisions[:limit]):
|
|
90
|
+
version = f"v{rev['version']}"
|
|
91
|
+
time_str = _parse_relative_time(rev.get("published_at", ""))
|
|
92
|
+
published_by = rev.get("published_by", "")
|
|
93
|
+
summary = rev.get("change_summary", "") or ""
|
|
94
|
+
|
|
95
|
+
# Mark the first one as CURRENT
|
|
96
|
+
if i == 0:
|
|
97
|
+
version = f"[bold green]{version}[/bold green]"
|
|
98
|
+
summary = f"{summary} [green]CURRENT[/green]"
|
|
99
|
+
|
|
100
|
+
table.add_row(version, time_str, published_by, summary)
|
|
101
|
+
|
|
102
|
+
console.print(f"\n[bold]Revision history: {connector_name or connector_id}[/bold]\n")
|
|
103
|
+
console.print(table)
|
|
104
|
+
console.print()
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""plugsync plugin -- manage TypeScript plugins (T10, issue #190).
|
|
2
|
+
|
|
3
|
+
Customers use this command group to author, bundle, upload, promote, and
|
|
4
|
+
inspect plugins. All network calls go to the plugsync HTTP API; no AWS
|
|
5
|
+
credentials are needed by the caller.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from plugsync_cli.bundler import bundle_plugin
|
|
15
|
+
from plugsync_cli.client import PlugSyncClient
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
_INDEX_TS_TEMPLATE = """\
|
|
20
|
+
/**
|
|
21
|
+
* Plugin entrypoint. Lambda invokes this as `index.handler`, so the export
|
|
22
|
+
* must be named `handler` (a default export will bundle fine but Lambda
|
|
23
|
+
* won't find it at invoke time). There is no `@plugsync/sdk` package -- see
|
|
24
|
+
* docs/content/reference/plugins/payload-contract.md for the full
|
|
25
|
+
* PluginPayload/PluginResponse shapes; copy the interfaces you need directly
|
|
26
|
+
* into this file.
|
|
27
|
+
*/
|
|
28
|
+
export async function handler(event: Record<string, unknown>): Promise<object> {
|
|
29
|
+
// Log structured JSON and keep connector_id/flow_name in every line: it is
|
|
30
|
+
// what lets CloudWatch (and `plugsync plugin logs --connector <id>`) filter
|
|
31
|
+
// this plugin's logs per connector.
|
|
32
|
+
console.log(JSON.stringify({
|
|
33
|
+
msg: "plugin invoked",
|
|
34
|
+
connector_id: event.connector_id,
|
|
35
|
+
flow_name: event.flow_name,
|
|
36
|
+
}));
|
|
37
|
+
// TODO: implement your plugin logic here
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_PACKAGE_JSON_TEMPLATE = """\
|
|
43
|
+
{{
|
|
44
|
+
"name": "{name}",
|
|
45
|
+
"version": "1.0.0",
|
|
46
|
+
"private": true,
|
|
47
|
+
"devDependencies": {{
|
|
48
|
+
"esbuild": "^0.21.0",
|
|
49
|
+
"typescript": "^5.0.0"
|
|
50
|
+
}},
|
|
51
|
+
"scripts": {{
|
|
52
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=cjs --outfile=dist/index.js"
|
|
53
|
+
}}
|
|
54
|
+
}}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_plugin_json(plugin_dir: Path) -> dict:
|
|
59
|
+
"""Read plugin.json from a plugin directory; exit 1 if missing/invalid."""
|
|
60
|
+
pj = plugin_dir / "plugin.json"
|
|
61
|
+
if not pj.exists():
|
|
62
|
+
console.print(
|
|
63
|
+
"[red]No plugin.json found. "
|
|
64
|
+
"Run 'plugsync plugin init <name>' first.[/red]"
|
|
65
|
+
)
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
with open(pj) as f:
|
|
68
|
+
return json.load(f)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_capabilities(meta: dict) -> dict:
|
|
72
|
+
"""Read and normalize plugin.json's `capabilities` for `POST /api/plugins`.
|
|
73
|
+
|
|
74
|
+
`plugsync plugin init` now scaffolds `capabilities` as an object, but
|
|
75
|
+
plugin.json files scaffolded before that fix (issue #651) have an
|
|
76
|
+
explicit `"capabilities": []`. The API only accepts a JSON object and
|
|
77
|
+
rejects an array with 422, even an empty one. Coerce the harmless
|
|
78
|
+
empty-list case so those legacy scaffolds still push; fail fast with a
|
|
79
|
+
clear message for a populated legacy list, since there is no way to
|
|
80
|
+
infer namespace/value pairs from a flat list here.
|
|
81
|
+
"""
|
|
82
|
+
capabilities = meta.get("capabilities", {})
|
|
83
|
+
if isinstance(capabilities, list):
|
|
84
|
+
if not capabilities:
|
|
85
|
+
console.print(
|
|
86
|
+
"[yellow]legacy scaffold: coercing capabilities [] to {}[/yellow]"
|
|
87
|
+
)
|
|
88
|
+
return {}
|
|
89
|
+
console.print(
|
|
90
|
+
f"[red]plugin.json's capabilities is a list ({capabilities!r}), "
|
|
91
|
+
"but the API requires a JSON object, e.g. "
|
|
92
|
+
'{"http": [...], "store": [...], "auth": [...]}. '
|
|
93
|
+
"Edit plugin.json and convert capabilities to that shape before "
|
|
94
|
+
"pushing.[/red]"
|
|
95
|
+
)
|
|
96
|
+
raise SystemExit(1)
|
|
97
|
+
return capabilities
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _handle_plugin_api_error(exc: httpx.HTTPStatusError) -> None:
|
|
101
|
+
"""Render an actionable message for a plugin API error, then exit 1.
|
|
102
|
+
|
|
103
|
+
Mirrors the pattern in commands/auth.py: inspect the response status and
|
|
104
|
+
the structured `detail` payload (`app.core.tier_limits.upgrade_error_
|
|
105
|
+
detail`) instead of letting an `httpx.HTTPStatusError` propagate as a raw
|
|
106
|
+
traceback. The 402 case (org tier lacks `plugin_system_enabled`,
|
|
107
|
+
`_assert_plugin_system_allowed` in app/api/plugins.py) is the one that
|
|
108
|
+
matters most here (issue #955): today it is discovered halfway through,
|
|
109
|
+
at push time, with zero actionable guidance.
|
|
110
|
+
"""
|
|
111
|
+
status_code = exc.response.status_code
|
|
112
|
+
try:
|
|
113
|
+
detail = exc.response.json().get("detail", "")
|
|
114
|
+
except Exception:
|
|
115
|
+
detail = str(exc)
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
status_code == 402
|
|
119
|
+
and isinstance(detail, dict)
|
|
120
|
+
and detail.get("error") == "plugin_system_not_allowed"
|
|
121
|
+
):
|
|
122
|
+
tier = detail.get("tier", "?")
|
|
123
|
+
message = detail.get("message", "The Plugin System is not available on your plan.")
|
|
124
|
+
upgrade_url = detail.get("upgrade_url", "")
|
|
125
|
+
console.print(f"[red]{message}[/red]")
|
|
126
|
+
console.print(f"[yellow]Current plan: {tier}. Upgrade: {upgrade_url}[/yellow]")
|
|
127
|
+
elif isinstance(detail, dict):
|
|
128
|
+
console.print(f"[red]Error ({status_code}): {detail.get('message', detail)}[/red]")
|
|
129
|
+
else:
|
|
130
|
+
console.print(f"[red]Error ({status_code}): {detail}[/red]")
|
|
131
|
+
raise SystemExit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@click.group("plugin")
|
|
135
|
+
def plugin():
|
|
136
|
+
"""Manage TypeScript plugins."""
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# init
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@plugin.command("init")
|
|
146
|
+
@click.argument("name")
|
|
147
|
+
def plugin_init(name: str):
|
|
148
|
+
"""Scaffold a new plugin directory with src/index.ts, plugin.json, package.json.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
plugsync plugin init my-validator
|
|
152
|
+
"""
|
|
153
|
+
dest = Path.cwd() / name
|
|
154
|
+
if dest.exists():
|
|
155
|
+
console.print(f"[red]Directory already exists: {dest}[/red]")
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
|
|
158
|
+
(dest / "src").mkdir(parents=True)
|
|
159
|
+
|
|
160
|
+
(dest / "src" / "index.ts").write_text(_INDEX_TS_TEMPLATE)
|
|
161
|
+
|
|
162
|
+
plugin_json = {"name": name, "id": None, "capabilities": {}}
|
|
163
|
+
(dest / "plugin.json").write_text(
|
|
164
|
+
json.dumps(plugin_json, indent=2) + "\n"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
(dest / "package.json").write_text(
|
|
168
|
+
_PACKAGE_JSON_TEMPLATE.format(name=name)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
console.print(f"[green]Scaffolded plugin: {dest}/[/green]")
|
|
172
|
+
console.print(
|
|
173
|
+
" Edit [bold]src/index.ts[/bold], then run "
|
|
174
|
+
"[bold]plugsync plugin push <dir>[/bold]."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# push
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@plugin.command("push")
|
|
184
|
+
@click.argument("path", required=False, default=None)
|
|
185
|
+
def plugin_push(path: str | None):
|
|
186
|
+
"""Bundle and upload a plugin to plugsync.
|
|
187
|
+
|
|
188
|
+
Reads plugin.json from the plugin directory for the plugin ID. If no ID
|
|
189
|
+
is set, creates the plugin first (saves the ID back to plugin.json).
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
plugsync plugin push ./my-validator
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
client = PlugSyncClient()
|
|
196
|
+
except RuntimeError as e:
|
|
197
|
+
console.print(f"[red]{e}[/red]")
|
|
198
|
+
raise SystemExit(1)
|
|
199
|
+
|
|
200
|
+
plugin_dir = Path(path) if path else Path.cwd()
|
|
201
|
+
if not plugin_dir.exists():
|
|
202
|
+
console.print(f"[red]Directory not found: {plugin_dir}[/red]")
|
|
203
|
+
raise SystemExit(1)
|
|
204
|
+
|
|
205
|
+
meta = _load_plugin_json(plugin_dir)
|
|
206
|
+
plugin_id = meta.get("id")
|
|
207
|
+
name = meta.get("name", plugin_dir.name)
|
|
208
|
+
|
|
209
|
+
if not plugin_id:
|
|
210
|
+
console.print(f"Creating plugin [bold]{name}[/bold] in plugsync...")
|
|
211
|
+
try:
|
|
212
|
+
created = client.plugin_create(name, _normalize_capabilities(meta))
|
|
213
|
+
except httpx.HTTPStatusError as exc:
|
|
214
|
+
_handle_plugin_api_error(exc)
|
|
215
|
+
plugin_id = created["id"]
|
|
216
|
+
meta["id"] = plugin_id
|
|
217
|
+
(plugin_dir / "plugin.json").write_text(json.dumps(meta, indent=2) + "\n")
|
|
218
|
+
console.print(f" Created plugin {plugin_id}")
|
|
219
|
+
|
|
220
|
+
console.print("Bundling...")
|
|
221
|
+
try:
|
|
222
|
+
bundle_bytes, bundle_hash = bundle_plugin(plugin_dir)
|
|
223
|
+
except RuntimeError as e:
|
|
224
|
+
console.print(f"[red]Bundle failed: {e}[/red]")
|
|
225
|
+
raise SystemExit(1)
|
|
226
|
+
|
|
227
|
+
entrypoint = plugin_dir / "src" / "index.ts"
|
|
228
|
+
source_code = entrypoint.read_text() if entrypoint.exists() else ""
|
|
229
|
+
|
|
230
|
+
console.print(f"Uploading bundle ({len(bundle_bytes)} bytes, sha256={bundle_hash[:12]}...)...")
|
|
231
|
+
try:
|
|
232
|
+
result = client.plugin_push(plugin_id, bundle_bytes, source_code)
|
|
233
|
+
except httpx.HTTPStatusError as exc:
|
|
234
|
+
_handle_plugin_api_error(exc)
|
|
235
|
+
console.print(f"[green]Pushed - status: {result.get('status', '?')}[/green]")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# promote / publish
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@plugin.command("promote")
|
|
244
|
+
@click.argument("plugin_id")
|
|
245
|
+
@click.argument("target", type=click.Choice(["staging", "live"]))
|
|
246
|
+
def plugin_promote_cmd(plugin_id: str, target: str):
|
|
247
|
+
"""Promote a plugin to staging or live.
|
|
248
|
+
|
|
249
|
+
Requires confirmation for the 'live' target.
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
plugsync plugin promote <id> staging
|
|
253
|
+
plugsync plugin promote <id> live
|
|
254
|
+
"""
|
|
255
|
+
if target == "live":
|
|
256
|
+
if not click.confirm(
|
|
257
|
+
f"Promote plugin {plugin_id} to LIVE? This affects all connectors."
|
|
258
|
+
):
|
|
259
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
260
|
+
raise SystemExit(0)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
client = PlugSyncClient()
|
|
264
|
+
except RuntimeError as e:
|
|
265
|
+
console.print(f"[red]{e}[/red]")
|
|
266
|
+
raise SystemExit(1)
|
|
267
|
+
|
|
268
|
+
result = client.plugin_promote(plugin_id, target)
|
|
269
|
+
console.print(
|
|
270
|
+
f"[green]Promotion enqueued - status: {result.get('status', '?')}[/green]"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@plugin.command("publish")
|
|
275
|
+
@click.argument("plugin_id")
|
|
276
|
+
@click.argument("target", type=click.Choice(["staging", "live"]), default="staging")
|
|
277
|
+
def plugin_publish_cmd(plugin_id: str, target: str):
|
|
278
|
+
"""Alias for 'promote'. Promote a plugin to staging or live."""
|
|
279
|
+
if target == "live":
|
|
280
|
+
if not click.confirm(
|
|
281
|
+
f"Promote plugin {plugin_id} to LIVE?"
|
|
282
|
+
):
|
|
283
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
284
|
+
raise SystemExit(0)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
client = PlugSyncClient()
|
|
288
|
+
except RuntimeError as e:
|
|
289
|
+
console.print(f"[red]{e}[/red]")
|
|
290
|
+
raise SystemExit(1)
|
|
291
|
+
|
|
292
|
+
result = client.plugin_promote(plugin_id, target)
|
|
293
|
+
console.print(
|
|
294
|
+
f"[green]Promotion enqueued - status: {result.get('status', '?')}[/green]"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# rollback
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@plugin.command("rollback")
|
|
304
|
+
@click.argument("plugin_id")
|
|
305
|
+
def plugin_rollback(plugin_id: str):
|
|
306
|
+
"""Roll a plugin back from live/staging to draft.
|
|
307
|
+
|
|
308
|
+
Not yet implemented - placeholder for Phase B.
|
|
309
|
+
"""
|
|
310
|
+
console.print("[yellow]Rollback is not yet available (Phase B).[/yellow]")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# status
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@plugin.command("status")
|
|
319
|
+
@click.argument("plugin_id")
|
|
320
|
+
def plugin_status_cmd(plugin_id: str):
|
|
321
|
+
"""Show the current status of a plugin.
|
|
322
|
+
|
|
323
|
+
Example:
|
|
324
|
+
plugsync plugin status <id>
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
client = PlugSyncClient()
|
|
328
|
+
except RuntimeError as e:
|
|
329
|
+
console.print(f"[red]{e}[/red]")
|
|
330
|
+
raise SystemExit(1)
|
|
331
|
+
|
|
332
|
+
info = client.plugin_status(plugin_id)
|
|
333
|
+
console.print(f"[bold]{info.get('name', plugin_id)}[/bold]")
|
|
334
|
+
console.print(f" id: {info.get('id', plugin_id)}")
|
|
335
|
+
console.print(f" status: {info.get('status', '?')}")
|
|
336
|
+
if info.get("bundle_hash"):
|
|
337
|
+
console.print(f" hash: {info['bundle_hash'][:12]}...")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# logs
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@plugin.command("logs")
|
|
346
|
+
@click.argument("plugin_id")
|
|
347
|
+
@click.option("--connector", "connector_id", default=None,
|
|
348
|
+
help="Filter lines to one connector id (#293).")
|
|
349
|
+
@click.option("--since", "since_minutes", type=int, default=None,
|
|
350
|
+
help="Lookback window in minutes (default: 60, max 1440).")
|
|
351
|
+
@click.option("--limit", type=int, default=None,
|
|
352
|
+
help="Max log lines (default: 50, max 1000).")
|
|
353
|
+
def plugin_logs_cmd(plugin_id: str, connector_id: str | None,
|
|
354
|
+
since_minutes: int | None, limit: int | None):
|
|
355
|
+
"""Tail recent CloudWatch log lines for a plugin.
|
|
356
|
+
|
|
357
|
+
Example:
|
|
358
|
+
plugsync plugin logs <id> --connector <connector-id> --since 240
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
client = PlugSyncClient()
|
|
362
|
+
except RuntimeError as e:
|
|
363
|
+
console.print(f"[red]{e}[/red]")
|
|
364
|
+
raise SystemExit(1)
|
|
365
|
+
|
|
366
|
+
result = client.plugin_logs(
|
|
367
|
+
plugin_id, connector_id=connector_id,
|
|
368
|
+
since_minutes=since_minutes, limit=limit,
|
|
369
|
+
)
|
|
370
|
+
lines = result.get("lines", [])
|
|
371
|
+
if not lines:
|
|
372
|
+
console.print("[yellow]No log lines available.[/yellow]")
|
|
373
|
+
return
|
|
374
|
+
for line in lines:
|
|
375
|
+
console.print(line)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# metrics (#293)
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@plugin.command("metrics")
|
|
384
|
+
@click.argument("plugin_id")
|
|
385
|
+
@click.option("--window", default="24h", type=click.Choice(["1h", "24h", "7d"]),
|
|
386
|
+
help="Aggregation window (default: 24h).")
|
|
387
|
+
def plugin_metrics_cmd(plugin_id: str, window: str):
|
|
388
|
+
"""Show CloudWatch metrics (invocations, errors, latency) for a plugin.
|
|
389
|
+
|
|
390
|
+
Example:
|
|
391
|
+
plugsync plugin metrics <id> --window 24h
|
|
392
|
+
"""
|
|
393
|
+
try:
|
|
394
|
+
client = PlugSyncClient()
|
|
395
|
+
except RuntimeError as e:
|
|
396
|
+
console.print(f"[red]{e}[/red]")
|
|
397
|
+
raise SystemExit(1)
|
|
398
|
+
|
|
399
|
+
data = client.plugin_metrics(plugin_id, window=window)
|
|
400
|
+
summary = data.get("summary", {})
|
|
401
|
+
console.print(f"[bold]Metrics ({data.get('window', window)})[/bold]")
|
|
402
|
+
console.print(f" invocations: {summary.get('invocations', 0):.0f}")
|
|
403
|
+
console.print(
|
|
404
|
+
f" errors: {summary.get('errors', 0):.0f}"
|
|
405
|
+
f" ({summary.get('error_rate', 0) * 100:.1f}%)"
|
|
406
|
+
)
|
|
407
|
+
console.print(f" throttles: {summary.get('throttles', 0):.0f}")
|
|
408
|
+
for pctl in ("p50", "p90", "p99"):
|
|
409
|
+
value = summary.get(f"duration_{pctl}_ms")
|
|
410
|
+
rendered = f"{value:.0f} ms" if value is not None else "n/a"
|
|
411
|
+
console.print(f" duration {pctl}: {rendered}")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# invoke
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@plugin.command("invoke")
|
|
420
|
+
@click.argument("plugin_id")
|
|
421
|
+
@click.option(
|
|
422
|
+
"--payload",
|
|
423
|
+
"-p",
|
|
424
|
+
default="{}",
|
|
425
|
+
help='JSON payload to send (default: "{}").',
|
|
426
|
+
)
|
|
427
|
+
def plugin_invoke_cmd(plugin_id: str, payload: str):
|
|
428
|
+
"""Invoke a plugin with a test payload.
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
plugsync plugin invoke <id> --payload '{"key": "value"}'
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
payload_dict = json.loads(payload)
|
|
435
|
+
except json.JSONDecodeError as e:
|
|
436
|
+
console.print(f"[red]Invalid JSON payload: {e}[/red]")
|
|
437
|
+
raise SystemExit(1)
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
client = PlugSyncClient()
|
|
441
|
+
except RuntimeError as e:
|
|
442
|
+
console.print(f"[red]{e}[/red]")
|
|
443
|
+
raise SystemExit(1)
|
|
444
|
+
|
|
445
|
+
result = client.plugin_invoke(plugin_id, payload_dict)
|
|
446
|
+
console.print(json.dumps(result, indent=2))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# list
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@plugin.command("list")
|
|
455
|
+
def plugin_list_cmd():
|
|
456
|
+
"""List all plugins for the current org."""
|
|
457
|
+
try:
|
|
458
|
+
client = PlugSyncClient()
|
|
459
|
+
except RuntimeError as e:
|
|
460
|
+
console.print(f"[red]{e}[/red]")
|
|
461
|
+
raise SystemExit(1)
|
|
462
|
+
|
|
463
|
+
plugins = client.plugin_list()
|
|
464
|
+
if not plugins:
|
|
465
|
+
console.print("[yellow]No plugins found.[/yellow]")
|
|
466
|
+
return
|
|
467
|
+
for p in plugins:
|
|
468
|
+
console.print(
|
|
469
|
+
f" {p.get('id', '?')} {p.get('name', '?')} [{p.get('status', '?')}]"
|
|
470
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""plugsync preview — preview changes with simulation."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from plugsync_cli.client import PlugSyncClient
|
|
11
|
+
from plugsync_cli.serializer import read_directory_to_zip
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.argument("path", required=False, default=None)
|
|
18
|
+
def preview(path: str | None):
|
|
19
|
+
"""Preview what would change if you publish now.
|
|
20
|
+
|
|
21
|
+
Uploads local state, diffs against published revision,
|
|
22
|
+
and shows a simulation of the effect.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
plugsync preview
|
|
26
|
+
plugsync preview ./hubspot-juve
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
client = PlugSyncClient()
|
|
30
|
+
except RuntimeError as e:
|
|
31
|
+
console.print(f"[red]{e}[/red]")
|
|
32
|
+
raise SystemExit(1)
|
|
33
|
+
|
|
34
|
+
connector_dir = Path(path) if path else Path.cwd()
|
|
35
|
+
if not (connector_dir / "plugsync.yaml").exists():
|
|
36
|
+
console.print("[red]No plugsync.yaml found.[/red]")
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
|
|
39
|
+
local_config = connector_dir / ".plugsync.yaml"
|
|
40
|
+
if not local_config.exists():
|
|
41
|
+
console.print("[red]No .plugsync.yaml found. Run 'plugsync pull' first.[/red]")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
|
|
44
|
+
with open(local_config) as f:
|
|
45
|
+
data = yaml.safe_load(f) or {}
|
|
46
|
+
connector_id = data.get("connector_id")
|
|
47
|
+
|
|
48
|
+
# Upload current state (required for server-side preview)
|
|
49
|
+
zip_data = read_directory_to_zip(connector_dir)
|
|
50
|
+
click.confirm(
|
|
51
|
+
"This will upload your local files to the remote working state. Continue?",
|
|
52
|
+
abort=True,
|
|
53
|
+
)
|
|
54
|
+
client.import_zip(connector_id, zip_data)
|
|
55
|
+
|
|
56
|
+
# Preview
|
|
57
|
+
result = client.preview(connector_id)
|
|
58
|
+
|
|
59
|
+
changes = result.get("changes", [])
|
|
60
|
+
warnings = result.get("warnings", [])
|
|
61
|
+
simulation = result.get("simulation")
|
|
62
|
+
summary = result.get("summary", "0 changes")
|
|
63
|
+
|
|
64
|
+
if not changes:
|
|
65
|
+
console.print("[green]No changes to publish.[/green]")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Changes table
|
|
69
|
+
console.print(f"\n[bold]Preview: {summary}[/bold]\n")
|
|
70
|
+
|
|
71
|
+
table = Table(show_header=True, header_style="bold")
|
|
72
|
+
table.add_column("Change", width=20)
|
|
73
|
+
table.add_column("Detail")
|
|
74
|
+
|
|
75
|
+
for change in changes:
|
|
76
|
+
change_type = change.get("type", "unknown")
|
|
77
|
+
detail = change.get("detail", "")
|
|
78
|
+
|
|
79
|
+
if "added" in change_type:
|
|
80
|
+
prefix = "[green]+[/green]"
|
|
81
|
+
elif "removed" in change_type:
|
|
82
|
+
prefix = "[red]-[/red]"
|
|
83
|
+
elif "modified" in change_type:
|
|
84
|
+
prefix = "[yellow]~[/yellow]"
|
|
85
|
+
else:
|
|
86
|
+
prefix = " "
|
|
87
|
+
|
|
88
|
+
table.add_row(f"{prefix} {change_type}", detail)
|
|
89
|
+
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
# Simulation
|
|
93
|
+
if simulation:
|
|
94
|
+
import json
|
|
95
|
+
console.print(Panel(
|
|
96
|
+
json.dumps(simulation, indent=2),
|
|
97
|
+
title="Simulation",
|
|
98
|
+
border_style="blue",
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
# Warnings
|
|
102
|
+
if warnings:
|
|
103
|
+
console.print("\n[yellow]Warnings:[/yellow]")
|
|
104
|
+
for w in warnings:
|
|
105
|
+
console.print(f" [yellow]! {w.get('detail', '')}[/yellow]")
|
|
106
|
+
|
|
107
|
+
console.print(f"\nRun [bold]plugsync push[/bold] to publish these changes.")
|