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,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.")