plugsync-cli 0.7.0__py3-none-any.whl → 0.8.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 CHANGED
@@ -1,2 +1,2 @@
1
1
  """PluSync CLI — git-like connector management."""
2
- __version__ = "0.7.0"
2
+ __version__ = "0.8.0"
plugsync_cli/client.py CHANGED
@@ -145,6 +145,98 @@ class PlugSyncClient:
145
145
  r.raise_for_status()
146
146
  return r.json()
147
147
 
148
+ def prewarm_start(
149
+ self,
150
+ connector_id: str,
151
+ *,
152
+ dry_run: bool = False,
153
+ modified_after: str | None = None,
154
+ entities: list[str] | None = None,
155
+ store_id: str | None = None,
156
+ ) -> dict:
157
+ """Start an identity map pre-warm job (#1885), returning the job.
158
+
159
+ The API answers 202 with the job already running in the background;
160
+ progress is read back with `prewarm_get`. `modified_after` is an ISO
161
+ 8601 timestamp with a timezone and turns the run into an incremental
162
+ pass. Unset options are left out of the body so the server defaults
163
+ apply.
164
+ """
165
+ body: dict = {"dry_run": dry_run}
166
+ if modified_after is not None:
167
+ body["modified_after"] = modified_after
168
+ if entities:
169
+ body["entities"] = list(entities)
170
+ if store_id is not None:
171
+ body["store_id"] = store_id
172
+ with self._client() as c:
173
+ r = c.post(
174
+ self._url(f"/connectors/{connector_id}/identity-prewarm"), json=body
175
+ )
176
+ r.raise_for_status()
177
+ return r.json()
178
+
179
+ def prewarm_list(self, connector_id: str) -> list[dict]:
180
+ """The connector's most recent pre-warm jobs, newest first."""
181
+ with self._client() as c:
182
+ r = c.get(self._url(f"/connectors/{connector_id}/identity-prewarm"))
183
+ r.raise_for_status()
184
+ return r.json().get("jobs", [])
185
+
186
+ def prewarm_get(self, connector_id: str, job_id: str) -> dict:
187
+ """One pre-warm job with its per-entity progress."""
188
+ with self._client() as c:
189
+ r = c.get(
190
+ self._url(f"/connectors/{connector_id}/identity-prewarm/{job_id}")
191
+ )
192
+ r.raise_for_status()
193
+ return r.json()
194
+
195
+ def prewarm_findings(
196
+ self,
197
+ connector_id: str,
198
+ job_id: str,
199
+ *,
200
+ finding: str | None = None,
201
+ limit: int = 200,
202
+ offset: int = 0,
203
+ ) -> dict:
204
+ """A page of a job's findings: ambiguous values and conflicting mappings."""
205
+ params: dict = {"limit": limit, "offset": offset}
206
+ if finding is not None:
207
+ params["finding"] = finding
208
+ with self._client() as c:
209
+ r = c.get(
210
+ self._url(
211
+ f"/connectors/{connector_id}/identity-prewarm/{job_id}/findings"
212
+ ),
213
+ params=params,
214
+ )
215
+ r.raise_for_status()
216
+ return r.json()
217
+
218
+ def prewarm_resume(self, connector_id: str, job_id: str) -> dict:
219
+ """Resume an interrupted or failed pre-warm job from its cursor."""
220
+ with self._client() as c:
221
+ r = c.post(
222
+ self._url(
223
+ f"/connectors/{connector_id}/identity-prewarm/{job_id}/resume"
224
+ )
225
+ )
226
+ r.raise_for_status()
227
+ return r.json()
228
+
229
+ def prewarm_cancel(self, connector_id: str, job_id: str) -> dict:
230
+ """Cancel a pre-warm job for good: it cannot be resumed afterwards."""
231
+ with self._client() as c:
232
+ r = c.post(
233
+ self._url(
234
+ f"/connectors/{connector_id}/identity-prewarm/{job_id}/cancel"
235
+ )
236
+ )
237
+ r.raise_for_status()
238
+ return r.json()
239
+
148
240
  def get_draft(self, connector_id: str) -> dict:
149
241
  """The connector's editable config (backend DraftResponse).
150
242
 
@@ -1,4 +1,4 @@
1
- """plugsync connector -- read a connector's flow circuit breakers, retry one.
1
+ """plugsync connector -- flow circuit breakers and the identity map pre-warm.
2
2
 
3
3
  Self-service only, with the same org-scoped auth as every other command: the
4
4
  owner of a connector lists the flows whose circuit breaker is open and retries
@@ -6,7 +6,14 @@ one of them, from a script or from CI, without opening the dashboard. The
6
6
  platform-side force-close is a different operation with a different
7
7
  authentication contract (it refuses an org API key) and deliberately has no
8
8
  command here.
9
+
10
+ `connector prewarm` drives the identity map pre-warm of a migration cutover
11
+ (#1885): it fills the connector's identity map from records already on the
12
+ HubSpot portal, so the first events after go-live update them instead of
13
+ searching for them. The job runs server-side; these commands start it, read
14
+ its progress, resume it and list what it could not map.
9
15
  """
16
+ import time
10
17
  import uuid
11
18
  from datetime import datetime, timezone
12
19
 
@@ -251,3 +258,402 @@ def connector_breakers(connector_reference: str):
251
258
  "by the next message it receives.\n",
252
259
  soft_wrap=True,
253
260
  )
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Identity map pre-warm (#1885)
265
+ # ---------------------------------------------------------------------------
266
+
267
+ _PREWARM_TERMINAL = frozenset({
268
+ "completed", "partial_failure", "failed", "cancelled", "superseded", "expired",
269
+ })
270
+ _PREWARM_POLL_SECONDS = 5
271
+ # --wait outlives a deploy of the API or a network blip: the job runs on the
272
+ # server either way. This many polls in a row may fail before it gives up.
273
+ _PREWARM_POLL_MAX_FAILURES = 12
274
+
275
+ # Machine codes of the pre-warm API whose own message is the whole story.
276
+ _PREWARM_ERRORS = frozenset({
277
+ "no_active_config",
278
+ "no_eligible_entities",
279
+ "entity_not_eligible",
280
+ "prewarm_already_running",
281
+ "job_not_resumable",
282
+ "job_not_cancellable",
283
+ })
284
+
285
+
286
+ def _structured_detail(exc: httpx.HTTPStatusError) -> dict:
287
+ try:
288
+ payload = exc.response.json()
289
+ except ValueError:
290
+ return {}
291
+ detail = payload.get("detail") if isinstance(payload, dict) else None
292
+ return detail if isinstance(detail, dict) else {}
293
+
294
+
295
+ def _prewarm_call(label: str, call, *args, **kwargs):
296
+ """Run one pre-warm request, turning the API's refusals into sentences."""
297
+ try:
298
+ return call(*args, **kwargs)
299
+ except httpx.HTTPStatusError as exc:
300
+ detail = _structured_detail(exc)
301
+ code = detail.get("error")
302
+ if exc.response.status_code == 404:
303
+ if code == "job_not_found":
304
+ fail(f"Pre-warm job not found on connector '{label}'.")
305
+ fail(_CONNECTOR_NOT_FOUND.format(label=label))
306
+ if code in _PREWARM_ERRORS:
307
+ message = detail.get("message") or code
308
+ if code == "entity_not_eligible":
309
+ message += (
310
+ f" Not eligible: {', '.join(detail.get('entities') or [])}."
311
+ f" Eligible: {', '.join(detail.get('eligible') or []) or 'none'}."
312
+ )
313
+ fail(f"{message} ({code})")
314
+ raise
315
+
316
+
317
+ def _render_job(job: dict) -> None:
318
+ """A job's header line and its per-entity counters."""
319
+ mode = "dry-run" if job.get("dry_run") else "write"
320
+ scope = (
321
+ f"incremental after {job['modified_after']}"
322
+ if job.get("modified_after") else "full"
323
+ )
324
+ store = f", store '{job['store_id']}'" if job.get("store_id") else ""
325
+ console.print(
326
+ f"\n[bold]Pre-warm {job.get('job_id')}[/bold]: {job.get('status')} "
327
+ f"({mode}, {scope}{store})",
328
+ soft_wrap=True,
329
+ )
330
+
331
+ # Single-cell padding: eight columns at the default two overflow an
332
+ # 80-column terminal and rich starts truncating the counters.
333
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
334
+ table.add_column("Entity", overflow="fold")
335
+ table.add_column("Phase")
336
+ for column in ("Scanned", "Mapped", "Present", "Conflicts", "Ambiguous", "Skipped"):
337
+ table.add_column(column, justify="right")
338
+ skipped_notes = []
339
+ for entry in job.get("entities") or []:
340
+ missing = entry.get("missing_value", 0)
341
+ invalid = entry.get("invalid_value", 0)
342
+ unmodified = entry.get("not_modified", 0)
343
+ skipped = missing + invalid + unmodified
344
+ table.add_row(
345
+ str(entry.get("entity") or ""),
346
+ str(entry.get("phase") or ""),
347
+ str(entry.get("scanned", 0)),
348
+ str(entry.get("mapped", 0)),
349
+ str(entry.get("already_present", 0)),
350
+ str(entry.get("conflicting", 0)),
351
+ str(entry.get("ambiguous_values", 0)),
352
+ str(skipped),
353
+ )
354
+ if skipped:
355
+ skipped_notes.append(
356
+ f"{entry.get('entity')}: skipped {missing} without an identity value, "
357
+ f"{invalid} with an invalid one, {unmodified} not modified since "
358
+ "the cutoff."
359
+ )
360
+ if entry.get("mapped_elsewhere"):
361
+ skipped_notes.append(
362
+ f"{entry.get('entity')}: {entry['mapped_elsewhere']} records already "
363
+ "mapped under another external id, not mapped again."
364
+ )
365
+ if entry.get("ambiguous_values"):
366
+ skipped_notes.append(
367
+ f"{entry.get('entity')}: {entry['ambiguous_values']} ambiguous values "
368
+ f"held by {entry.get('ambiguous_records', 0)} records, not mapped."
369
+ )
370
+ console.print(table)
371
+ for note in skipped_notes:
372
+ console.print(note, soft_wrap=True)
373
+
374
+ for entry in job.get("entities") or []:
375
+ if entry.get("error"):
376
+ console.print(
377
+ f"[red]{entry.get('entity')}: {entry['error']}[/red]", soft_wrap=True
378
+ )
379
+ if job.get("last_error"):
380
+ console.print(f"[red]Job error: {job['last_error']}[/red]", soft_wrap=True)
381
+ if job.get("dry_run") and job.get("status") in _PREWARM_TERMINAL:
382
+ console.print("Dry run: nothing was written. 'Mapped' is what a real run would write.")
383
+ if job.get("resumable"):
384
+ console.print(
385
+ f"Resumable from where it stopped: plugsync connector prewarm resume "
386
+ f"<connector> {job.get('job_id')}",
387
+ soft_wrap=True,
388
+ )
389
+
390
+
391
+ def _has_findings(job: dict) -> bool:
392
+ return any(
393
+ (entry.get("ambiguous_values") or 0) or (entry.get("conflicting") or 0)
394
+ or (entry.get("mapped_elsewhere") or 0)
395
+ for entry in job.get("entities") or []
396
+ )
397
+
398
+
399
+ def _is_transient(exc: Exception) -> bool:
400
+ """A failed poll that says nothing about the job: the API restarting
401
+ (5xx), a rate limit, the network."""
402
+ if isinstance(exc, httpx.HTTPStatusError):
403
+ return exc.response.status_code >= 500 or exc.response.status_code == 429
404
+ return isinstance(exc, httpx.TransportError)
405
+
406
+
407
+ def _wait_for(client: PlugSyncClient, connector_id: str, label: str, job: dict) -> dict:
408
+ """Poll a job until it reaches a terminal status.
409
+
410
+ A poll that fails for a transient reason is tried again at the next
411
+ tick: the job runs on the server and is not affected, so exiting
412
+ non-zero here would report a failure that did not happen. After
413
+ `_PREWARM_POLL_MAX_FAILURES` failed polls in a row it gives up, saying
414
+ that the job's state is unknown, not that it failed.
415
+ """
416
+ failures = 0
417
+ while job.get("status") not in _PREWARM_TERMINAL:
418
+ if job.get("resumable"):
419
+ # A running job whose heartbeat went stale: nobody is advancing it.
420
+ break
421
+ time.sleep(_PREWARM_POLL_SECONDS)
422
+ try:
423
+ job = _prewarm_call(label, client.prewarm_get, connector_id, str(job["job_id"]))
424
+ except (httpx.HTTPStatusError, httpx.TransportError) as exc:
425
+ if not _is_transient(exc):
426
+ raise
427
+ failures += 1
428
+ reason = (
429
+ f"HTTP {exc.response.status_code}"
430
+ if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
431
+ )
432
+ if failures >= _PREWARM_POLL_MAX_FAILURES:
433
+ fail(
434
+ f"Lost contact with the API while waiting ({failures} polls "
435
+ f"failed in a row, last: {reason}). The pre-warm may still be "
436
+ f"running: check it with plugsync connector prewarm status "
437
+ f"{label} {job.get('job_id')}"
438
+ )
439
+ console.print(
440
+ f"[yellow]Status poll failed ({reason}), retrying "
441
+ f"({failures}/{_PREWARM_POLL_MAX_FAILURES}).[/yellow]",
442
+ soft_wrap=True,
443
+ )
444
+ continue
445
+ failures = 0
446
+ return job
447
+
448
+
449
+ def _finish(job: dict, label: str) -> None:
450
+ """Report a job `--wait` stopped on; exit non-zero unless it completed."""
451
+ _render_job(job)
452
+ if _has_findings(job):
453
+ console.print(
454
+ f"Review what was not mapped: plugsync connector prewarm findings "
455
+ f"{label} {job.get('job_id')}",
456
+ soft_wrap=True,
457
+ )
458
+ status = job.get("status")
459
+ if status not in _PREWARM_TERMINAL:
460
+ # --wait gave up on a job nobody is advancing: a script chaining the
461
+ # cutover on this exit code must not read that as a finished pre-warm.
462
+ console.print(
463
+ f"[red]The pre-warm stopped advancing before it finished "
464
+ f"(status: {status}).[/red]",
465
+ soft_wrap=True,
466
+ )
467
+ if status != "completed":
468
+ raise SystemExit(1)
469
+
470
+
471
+ @connector.group()
472
+ def prewarm():
473
+ """Pre-warm a connector's identity map from its HubSpot portal."""
474
+ pass
475
+
476
+
477
+ @prewarm.command("start")
478
+ @click.argument("connector_reference", metavar="CONNECTOR")
479
+ @click.option("--dry-run", is_flag=True,
480
+ help="Count per entity what would be mapped, write nothing.")
481
+ @click.option("--modified-after", metavar="TIMESTAMP",
482
+ help="Incremental pass: map only records modified after this ISO 8601 "
483
+ "timestamp, with a timezone (e.g. 2026-10-01T08:00:00Z).")
484
+ @click.option("--entity", "entities", multiple=True, metavar="ENTITY",
485
+ help="Limit the run to this entity. Repeatable. Default: every entity "
486
+ "with a HubSpot schema and an identity property.")
487
+ @click.option("--store-id", metavar="STORE",
488
+ help="Store id of the keys written. Only for records that will arrive "
489
+ "through bulk import with that storeId; event flows need none.")
490
+ @click.option("--wait", is_flag=True,
491
+ help="Poll until the job finishes and exit non-zero if it did not complete.")
492
+ def prewarm_start(connector_reference: str, dry_run: bool, modified_after: str | None,
493
+ entities: tuple[str, ...], store_id: str | None, wait: bool):
494
+ """Start an identity map pre-warm.
495
+
496
+ CONNECTOR is a connector name or id. The job reads every record of the
497
+ eligible entities from HubSpot and writes the identity mappings that are
498
+ missing. Existing mappings are never overwritten, and a value held by more
499
+ than one HubSpot record is reported as ambiguous instead of mapped.
500
+
501
+ Examples:
502
+ plugsync connector prewarm start my-connector --dry-run --wait
503
+ plugsync connector prewarm start my-connector
504
+ plugsync connector prewarm start my-connector --modified-after 2026-10-01T08:00:00Z --wait
505
+ """
506
+ if modified_after is not None:
507
+ try:
508
+ parsed = datetime.fromisoformat(modified_after.replace("Z", "+00:00"))
509
+ except ValueError:
510
+ fail(f"--modified-after '{modified_after}' is not an ISO 8601 timestamp.")
511
+ if parsed.tzinfo is None:
512
+ fail("--modified-after needs a timezone, e.g. 2026-10-01T08:00:00Z.")
513
+
514
+ client = build_client()
515
+ connector_id, label = _resolve_connector(client, connector_reference)
516
+ job = _prewarm_call(
517
+ label, client.prewarm_start, connector_id,
518
+ dry_run=dry_run, modified_after=modified_after,
519
+ entities=list(entities) or None, store_id=store_id,
520
+ )
521
+ if not wait:
522
+ _render_job(job)
523
+ console.print(
524
+ f"Follow it with: plugsync connector prewarm status {label} {job.get('job_id')}",
525
+ soft_wrap=True,
526
+ )
527
+ return
528
+ _finish(_wait_for(client, connector_id, label, job), label)
529
+
530
+
531
+ @prewarm.command("status")
532
+ @click.argument("connector_reference", metavar="CONNECTOR")
533
+ @click.argument("job_id", required=False)
534
+ @click.option("--wait", is_flag=True, help="Poll until the job finishes.")
535
+ def prewarm_status(connector_reference: str, job_id: str | None, wait: bool):
536
+ """Show a pre-warm job's progress (default: the most recent job).
537
+
538
+ Examples:
539
+ plugsync connector prewarm status my-connector
540
+ plugsync connector prewarm status my-connector <job-id> --wait
541
+ """
542
+ client = build_client()
543
+ connector_id, label = _resolve_connector(client, connector_reference)
544
+ if job_id is None:
545
+ jobs = _prewarm_call(label, client.prewarm_list, connector_id)
546
+ if not jobs:
547
+ console.print(f"No pre-warm job on '{label}' yet.")
548
+ return
549
+ job = jobs[0]
550
+ else:
551
+ job = _prewarm_call(label, client.prewarm_get, connector_id, job_id)
552
+ if wait:
553
+ _finish(_wait_for(client, connector_id, label, job), label)
554
+ return
555
+ _render_job(job)
556
+
557
+
558
+ @prewarm.command("resume")
559
+ @click.argument("connector_reference", metavar="CONNECTOR")
560
+ @click.argument("job_id")
561
+ @click.option("--wait", is_flag=True, help="Poll until the job finishes.")
562
+ def prewarm_resume(connector_reference: str, job_id: str, wait: bool):
563
+ """Resume a failed or interrupted pre-warm from its last saved page.
564
+
565
+ A job can be resumed when it failed (for example after a long HubSpot
566
+ rate limit) or when it is marked running but its heartbeat is older than
567
+ five minutes (the process running it stopped). Only the connector's
568
+ latest job can be resumed, and never a cancelled, superseded or expired
569
+ one: start a new pre-warm instead.
570
+ """
571
+ client = build_client()
572
+ connector_id, label = _resolve_connector(client, connector_reference)
573
+ job = _prewarm_call(label, client.prewarm_resume, connector_id, job_id)
574
+ if wait:
575
+ _finish(_wait_for(client, connector_id, label, job), label)
576
+ return
577
+ _render_job(job)
578
+
579
+
580
+ @prewarm.command("cancel")
581
+ @click.argument("connector_reference", metavar="CONNECTOR")
582
+ @click.argument("job_id")
583
+ def prewarm_cancel(connector_reference: str, job_id: str):
584
+ """Cancel a pre-warm job for good.
585
+
586
+ Works on a queued, running or failed job. Use it to stop a run, or to
587
+ free a connector whose job is stuck in `running` because the process
588
+ running it stopped: until then no new pre-warm can start. A running job
589
+ stops at its next page. The mappings already written stay (a later run
590
+ finds them present); the job's staged values are deleted and it can
591
+ never be resumed.
592
+
593
+ Example:
594
+ plugsync connector prewarm cancel my-connector <job-id>
595
+ """
596
+ client = build_client()
597
+ connector_id, label = _resolve_connector(client, connector_reference)
598
+ job = _prewarm_call(label, client.prewarm_cancel, connector_id, job_id)
599
+ _render_job(job)
600
+ console.print(
601
+ f"Cancelled. Start again with: plugsync connector prewarm start {label}",
602
+ soft_wrap=True,
603
+ )
604
+
605
+
606
+ @prewarm.command("findings")
607
+ @click.argument("connector_reference", metavar="CONNECTOR")
608
+ @click.argument("job_id")
609
+ @click.option("--finding",
610
+ type=click.Choice(["ambiguous", "conflict", "mapped_elsewhere"]),
611
+ help="Only this kind of finding.")
612
+ @click.option("--limit", type=click.IntRange(1, 1000), default=200, show_default=True)
613
+ @click.option("--offset", type=click.IntRange(0), default=0, show_default=True)
614
+ def prewarm_findings(connector_reference: str, job_id: str, finding: str | None,
615
+ limit: int, offset: int):
616
+ """List what a pre-warm could not map.
617
+
618
+ `ambiguous`: the same identity value is held by several HubSpot records,
619
+ so no mapping was written; deduplicate them on the portal and run the
620
+ pre-warm again. `conflict`: the identity map already maps the value to a
621
+ different record and was left untouched. `mapped_elsewhere`: the record
622
+ is already mapped under another external id (shown in the last column),
623
+ so it was not given a second mapping.
624
+ """
625
+ client = build_client()
626
+ connector_id, label = _resolve_connector(client, connector_reference)
627
+ page = _prewarm_call(
628
+ label, client.prewarm_findings, connector_id, job_id,
629
+ finding=finding, limit=limit, offset=offset,
630
+ )
631
+ rows = page.get("findings") or []
632
+ total = page.get("total", len(rows))
633
+ if not rows:
634
+ console.print(f"[green]No findings for job {job_id}.[/green]")
635
+ return
636
+
637
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
638
+ table.add_column("Entity")
639
+ table.add_column("Finding")
640
+ table.add_column("Value", overflow="fold")
641
+ table.add_column("HubSpot id")
642
+ table.add_column("Mapped to")
643
+ for row in rows:
644
+ table.add_row(
645
+ str(row.get("entity") or ""),
646
+ str(row.get("finding") or ""),
647
+ str(row.get("value") or ""),
648
+ str(row.get("hubspot_id") or ""),
649
+ str(row.get("existing_hubspot_id") or row.get("existing_external_id") or ""),
650
+ )
651
+ console.print(table)
652
+ shown_to = offset + len(rows)
653
+ console.print(f"\n{offset + 1}-{shown_to} of {total}.")
654
+ if shown_to < total:
655
+ console.print(
656
+ f"Next page: plugsync connector prewarm findings {label} {job_id} "
657
+ f"--offset {shown_to}",
658
+ soft_wrap=True,
659
+ )
@@ -35,6 +35,16 @@ export async function handler(event: Record<string, unknown>): Promise<object> {
35
35
  flow_name: event.flow_name,
36
36
  }));
37
37
  // TODO: implement your plugin logic here
38
+ //
39
+ // The response is a plain object; later flow steps read any key of it as
40
+ // `$<step_id>.<key>`. Keys the runtime interprets:
41
+ // return { action: "skip" }; // stop the flow here
42
+ // return { action: "retry", reason: "HTTP 503" }; // transient, retried
43
+ // return { outcome: "failed", reason: "rejected" }; // target refused the
44
+ // record: later steps still run, the record ends failed and replayable
45
+ // store_writes: [{ key, value, ttl_seconds? }] // needs store:write;
46
+ // keys persist for the life of the connector (deleted with it);
47
+ // ttl_seconds opts a key into expiry
38
48
  return {};
39
49
  }
40
50
  """
@@ -1,18 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
- License: Proprietary
6
+ License-Expression: LicenseRef-Proprietary
7
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
8
+ Project-URL: Documentation, https://docs.plugsync.com/reference/cli/
9
+ Project-URL: Terms of Service, https://plugsync.com/it/terms
11
10
  Keywords: plugsync,hubspot,cli,connectors,integration
12
11
  Classifier: Development Status :: 4 - Beta
13
12
  Classifier: Environment :: Console
14
13
  Classifier: Intended Audience :: Developers
15
- Classifier: License :: Other/Proprietary License
16
14
  Classifier: Operating System :: OS Independent
17
15
  Classifier: Programming Language :: Python :: 3
18
16
  Classifier: Programming Language :: Python :: 3.12
@@ -20,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
20
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
19
  Requires-Python: >=3.12
22
20
  Description-Content-Type: text/markdown
21
+ License-File: LICENSE
23
22
  Requires-Dist: click>=8.1
24
23
  Requires-Dist: httpx>=0.27
25
24
  Requires-Dist: pyyaml>=6.0
@@ -28,6 +27,7 @@ Provides-Extra: dev
28
27
  Requires-Dist: pytest>=8.0; extra == "dev"
29
28
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
29
  Requires-Dist: respx>=0.21; extra == "dev"
30
+ Dynamic: license-file
31
31
 
32
32
  # plugsync-cli
33
33
 
@@ -91,11 +91,13 @@ against the API at the start of any command that talks to it, warning
91
91
  ## Documentation
92
92
 
93
93
  Full reference docs, including the plugin authoring dev-loop, live at
94
- [docs.plugsync.com](https://docs.plugsync.com) and in the
95
- [plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
94
+ [docs.plugsync.com](https://docs.plugsync.com).
96
95
 
97
96
  ## License
98
97
 
99
- Proprietary. This CLI talks to the plugsync SaaS API; see
100
- [plugsync.com/terms](https://plugsync.com/terms) for the terms governing use
101
- of the plugsync service.
98
+ Proprietary, not open source. The license to install and use this CLI is
99
+ granted by paragraph 11.2 of the plugsync Terms of Service, published at
100
+ [plugsync.com/it/terms](https://plugsync.com/it/terms): a non-exclusive,
101
+ non-transferable, free-of-charge license, for the term of your plugsync
102
+ agreement, solely for the purpose of using the plugsync service. The
103
+ `LICENSE` file shipped with the package carries the full notice.
@@ -1,6 +1,6 @@
1
- plugsync_cli/__init__.py,sha256=PlGlt8CSRA3Ay-FfIqLXUuPNEOUwWaen2HnLvTWZukI,75
1
+ plugsync_cli/__init__.py,sha256=eDPwXO1lIo5lCvUfCDMdO6mSxWqST0tX30uKPMmCUWU,75
2
2
  plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
3
- plugsync_cli/client.py,sha256=3AklXqJAtX4Jo6dk313-_f_oQFpawX2nUwUdaO-lFqM,15059
3
+ plugsync_cli/client.py,sha256=k_hHOZG99MRwOPti9zCfniFzcIxCvatpBkodR1x3RRU,18402
4
4
  plugsync_cli/compat.py,sha256=DCN-DO8FVQDjTz3sgMW3JAjIAWNhplan4zibDnwjK9w,4106
5
5
  plugsync_cli/config.py,sha256=z2DntLCY1RPU5gcqdDgkJ97fRd18clsXTvAlzLLcz10,1888
6
6
  plugsync_cli/context.py,sha256=5sk5_xkuL7LsE0fDitWVsKUArwx18Vyr47mgs-uMrUU,10624
@@ -10,17 +10,18 @@ plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzm
10
10
  plugsync_cli/serializer.py,sha256=Nspm510tbgwI_udiPpNeXjO_NVWL-T1VEC9uvpXYqMM,25620
11
11
  plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
12
12
  plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
13
- plugsync_cli/commands/connector.py,sha256=FzoK7P5m-siCUpYeFkXbCzimMU_UePmbaP-wqcM_DU0,8751
13
+ plugsync_cli/commands/connector.py,sha256=_hmxZNMqP7XNo-DD3QreV39UzLr3oRKmLmKd3U0756E,25707
14
14
  plugsync_cli/commands/diff.py,sha256=zgTA5FeJLPnr-kiQaUezEyF5Wwq_2Gv4qnKnxvNkA7Q,19664
15
15
  plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
16
- plugsync_cli/commands/plugin.py,sha256=as1BM6lxpPcdrE12D--BCbSiriCLJbZED8utqJIGos8,20018
16
+ plugsync_cli/commands/plugin.py,sha256=ziLaciAVFTCQp18-mwIYaPriWhKR1zWS0Pldxsjp6OQ,20672
17
17
  plugsync_cli/commands/preview.py,sha256=7MyGDqyb_6XLiO6-CFyNJDBuD2BXqfKg8Y2U3ax2ptM,2960
18
18
  plugsync_cli/commands/pull.py,sha256=UXXaINuTHo3sroXAmfA7BZFQj7eG8OKrWCbvhu1Y05o,2479
19
19
  plugsync_cli/commands/push.py,sha256=xZNpRY4wAcTnkkw59Tg18lRoYilFCC-t8SR1RvC5CQA,6520
20
20
  plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
21
21
  plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
22
- plugsync_cli-0.7.0.dist-info/METADATA,sha256=0ME4zUbvMWbTXNShCGxCbFasqd4utL2z-mnrOWKs5AA,4377
23
- plugsync_cli-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
- plugsync_cli-0.7.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
25
- plugsync_cli-0.7.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
26
- plugsync_cli-0.7.0.dist-info/RECORD,,
22
+ plugsync_cli-0.8.0.dist-info/licenses/LICENSE,sha256=5op7H-LOvR3Worm5Caabx48Er2AUe8Kp0eLCJoIoCYQ,1598
23
+ plugsync_cli-0.8.0.dist-info/METADATA,sha256=QwLX7YQd-W1Ty6bn_phSiXNYbREgIOsihAzQg5N7O-o,4466
24
+ plugsync_cli-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ plugsync_cli-0.8.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
26
+ plugsync_cli-0.8.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
27
+ plugsync_cli-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,35 @@
1
+ plugsync-cli
2
+ Copyright (c) 2026 Exelab S.r.l. All rights reserved.
3
+
4
+ This package is the plugsync command-line interface (the "CLI"), client
5
+ software distributed by Exelab S.r.l. ("Exelab") for use with the plugsync
6
+ service. It is not open source software.
7
+
8
+ The right to install and use the CLI is granted by paragraph 11.2 of the
9
+ plugsync Terms of Service (the "Terms"), published at:
10
+
11
+ https://plugsync.com/it/terms
12
+
13
+ Paragraph 11.2 of the Terms reads:
14
+
15
+ 11.2 Software client. Per la durata del Contratto Exelab concede al
16
+ Cliente una licenza non esclusiva, non trasferibile e gratuita di
17
+ installare e utilizzare la CLI e il Server MCP al solo fine di utilizzare
18
+ il Servizio, nel rispetto dell'Articolo 6.
19
+
20
+ (Courtesy translation: For the term of the Agreement, Exelab grants the
21
+ Customer a non-exclusive, non-transferable, free-of-charge license to
22
+ install and use the CLI and the MCP Server solely for the purpose of using
23
+ the Service, in compliance with Article 6.)
24
+
25
+ No other right is granted. Exelab remains the sole owner of the CLI and of
26
+ every intellectual property right in it (paragraph 11.1 of the Terms).
27
+ Warranties and liability are governed by the Terms. Capitalised words have
28
+ the meaning given to them in the Terms.
29
+
30
+ The Terms are drafted in Italian, which is the authoritative text; any
31
+ translation, including the one above, is provided as a courtesy only. Where
32
+ this notice and the Terms differ, the Terms prevail.
33
+
34
+ Third-party components that the CLI depends on are distributed under their
35
+ own open source licenses, which continue to govern them.