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,121 @@
1
+ """plugsync pull — download connector as local directory."""
2
+ import io
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import yaml
8
+ from rich.console import Console
9
+
10
+ from plugsync_cli.client import PlugSyncClient
11
+ from plugsync_cli.serializer import write_directory
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.command()
17
+ @click.argument("connector_name")
18
+ @click.option("--revision", "-r", type=int, default=None, help="Export a specific revision version")
19
+ @click.option("--output", "-o", type=click.Path(), default=None, help="Output directory path")
20
+ def pull(connector_name: str, revision: int | None, output: str | None):
21
+ """Download a connector as a local directory.
22
+
23
+ Examples:
24
+ plugsync pull hubspot-juve
25
+ plugsync pull hubspot-juve --revision 3
26
+ plugsync pull hubspot-juve -o ./my-dir
27
+ """
28
+ try:
29
+ client = PlugSyncClient()
30
+ except RuntimeError as e:
31
+ console.print(f"[red]{e}[/red]")
32
+ raise SystemExit(1)
33
+
34
+ # Find connector by name
35
+ connector = client.find_connector(connector_name)
36
+ if not connector:
37
+ console.print(f"[red]Connector '{connector_name}' not found[/red]")
38
+ raise SystemExit(1)
39
+
40
+ connector_id = connector["id"]
41
+
42
+ # Download ZIP
43
+ if revision is not None:
44
+ console.print(f"Exporting revision v{revision} of [bold]{connector_name}[/bold]...")
45
+ zip_data = client.export_revision(connector_id, revision)
46
+ else:
47
+ console.print(f"Exporting [bold]{connector_name}[/bold]...")
48
+ zip_data = client.export_working_state(connector_id)
49
+
50
+ # Extract ZIP to local directory
51
+ output_dir = Path(output) if output else Path.cwd() / connector_name
52
+
53
+ buf = io.BytesIO(zip_data)
54
+ with zipfile.ZipFile(buf, "r") as zf:
55
+ # Find the prefix directory in the ZIP
56
+ names = zf.namelist()
57
+ if not names:
58
+ console.print("[red]Empty export[/red]")
59
+ raise SystemExit(1)
60
+
61
+ prefix = names[0].split("/")[0]
62
+
63
+ # Parse manifest
64
+ manifest_content = zf.read(f"{prefix}/plugsync.yaml").decode("utf-8")
65
+ manifest = yaml.safe_load(manifest_content)
66
+
67
+ # Parse entities
68
+ entities = []
69
+ for name in sorted(names):
70
+ if name.startswith(f"{prefix}/entities/") and name.endswith(".yaml"):
71
+ data = yaml.safe_load(zf.read(name).decode("utf-8"))
72
+ entity_name = data.get("name", Path(name).stem)
73
+ config = {}
74
+ for key in ("entity_type", "hubspot_object_type", "identity", "field_mappings", "associations", "scope_overrides"):
75
+ if key in data:
76
+ config[key] = data[key]
77
+ entities.append({"name": entity_name, "config": config})
78
+
79
+ # Parse handlers
80
+ handlers = []
81
+ for name in sorted(names):
82
+ if name.startswith(f"{prefix}/handlers/") and name.endswith(".py"):
83
+ content = zf.read(name).decode("utf-8")
84
+ lines = content.split("\n")
85
+ event_type = None
86
+ code = content
87
+ if lines[0].startswith("# event_type:"):
88
+ event_type = lines[0].split(":", 1)[1].strip()
89
+ code = "\n".join(lines[1:]).lstrip("\n")
90
+
91
+ if not event_type:
92
+ stem = Path(name).stem
93
+ event_type = stem.replace("_", ".")
94
+
95
+ # Extract function name
96
+ fn_name = "handler"
97
+ for line in code.split("\n"):
98
+ s = line.strip()
99
+ if s.startswith("async def "):
100
+ fn_name = s.split("(")[0].replace("async def ", "")
101
+ break
102
+ elif s.startswith("def "):
103
+ fn_name = s.split("(")[0].replace("def ", "")
104
+ break
105
+
106
+ handlers.append({"event_type": event_type, "function_name": fn_name, "code": code})
107
+
108
+ # Write to local directory
109
+ write_directory(output_dir, manifest, entities, handlers)
110
+
111
+ # Write .plugsync.yaml in the directory for context
112
+ local_config = output_dir / ".plugsync.yaml"
113
+ local_config.write_text(yaml.dump({
114
+ "connector": connector_name,
115
+ "connector_id": connector_id,
116
+ }, default_flow_style=False))
117
+
118
+ entity_count = len(entities)
119
+ handler_count = len(handlers)
120
+ console.print(f"[green]Pulled to {output_dir}/[/green]")
121
+ console.print(f" {entity_count} entit{'y' if entity_count == 1 else 'ies'}, {handler_count} handler{'s' if handler_count != 1 else ''}")
@@ -0,0 +1,101 @@
1
+ """plugsync push — upload local directory, validate, and publish."""
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import yaml
6
+ from rich.console import Console
7
+
8
+ from plugsync_cli.client import PlugSyncClient
9
+ from plugsync_cli.serializer import read_directory_to_zip
10
+
11
+ console = Console()
12
+
13
+
14
+ def _find_connector_dir(path: str | None) -> Path:
15
+ """Resolve connector directory from argument or cwd."""
16
+ if path:
17
+ p = Path(path)
18
+ if not p.exists():
19
+ console.print(f"[red]Directory not found: {path}[/red]")
20
+ raise SystemExit(1)
21
+ return p
22
+
23
+ # Check cwd for plugsync.yaml
24
+ cwd = Path.cwd()
25
+ if (cwd / "plugsync.yaml").exists():
26
+ return cwd
27
+
28
+ console.print("[red]No plugsync.yaml found in current directory. Specify a path or cd into a connector directory.[/red]")
29
+ raise SystemExit(1)
30
+
31
+
32
+ def _get_connector_id(connector_dir: Path) -> str:
33
+ """Read connector_id from .plugsync.yaml."""
34
+ local_config = connector_dir / ".plugsync.yaml"
35
+ if local_config.exists():
36
+ with open(local_config) as f:
37
+ data = yaml.safe_load(f) or {}
38
+ cid = data.get("connector_id")
39
+ if cid:
40
+ return cid
41
+
42
+ console.print("[red]No .plugsync.yaml with connector_id found. Run 'plugsync pull' first.[/red]")
43
+ raise SystemExit(1)
44
+
45
+
46
+ @click.command()
47
+ @click.argument("path", required=False, default=None)
48
+ @click.option("--message", "-m", type=str, default=None, help="Change summary for publish")
49
+ @click.option("--no-publish", is_flag=True, default=False, help="Import without publishing")
50
+ def push(path: str | None, message: str | None, no_publish: bool):
51
+ """Upload local directory to PluSync, validate, and publish.
52
+
53
+ Examples:
54
+ plugsync push # push from cwd
55
+ plugsync push ./hubspot-juve # push from specific path
56
+ plugsync push -m "Added phone" # with change summary
57
+ plugsync push --no-publish # import without publishing
58
+ """
59
+ try:
60
+ client = PlugSyncClient()
61
+ except RuntimeError as e:
62
+ console.print(f"[red]{e}[/red]")
63
+ raise SystemExit(1)
64
+
65
+ connector_dir = _find_connector_dir(path)
66
+ connector_id = _get_connector_id(connector_dir)
67
+
68
+ console.print(f"Packing [bold]{connector_dir.name}[/bold]...")
69
+ zip_data = read_directory_to_zip(connector_dir)
70
+
71
+ # Upload
72
+ console.print("Uploading...")
73
+ import_result = client.import_zip(connector_id, zip_data)
74
+ console.print(
75
+ f" Imported: {import_result['entities_count']} entities, "
76
+ f"{import_result['handlers_count']} handlers"
77
+ )
78
+
79
+ # Validate
80
+ console.print("Validating...")
81
+ validation = client.validate(connector_id)
82
+ if not validation["valid"]:
83
+ console.print("[red]Validation failed:[/red]")
84
+ for err in validation.get("errors", []):
85
+ loc = err.get("location", "")
86
+ msg = err.get("message", "")
87
+ console.print(f" [red]{loc}: {msg}[/red]")
88
+ console.print("[yellow]Changes imported but NOT published. Fix errors and push again.[/yellow]")
89
+ raise SystemExit(1)
90
+
91
+ console.print("[green]Validation passed[/green]")
92
+
93
+ if no_publish:
94
+ console.print("[yellow]Skipping publish (--no-publish). Changes are in working state.[/yellow]")
95
+ return
96
+
97
+ # Publish
98
+ console.print("Publishing...")
99
+ pub_result = client.publish(connector_id, change_summary=message)
100
+ version = pub_result.get("version", "?")
101
+ console.print(f"[green]Published revision v{version}[/green]")
@@ -0,0 +1,72 @@
1
+ """plugsync rollback — restore working state from a past revision."""
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import yaml
6
+ from rich.console import Console
7
+
8
+ from plugsync_cli.client import PlugSyncClient
9
+
10
+ console = Console()
11
+
12
+
13
+ def _parse_version(version_str: str) -> int:
14
+ """Parse version from 'v3' or '3'."""
15
+ cleaned = version_str.strip().lower().lstrip("v")
16
+ try:
17
+ return int(cleaned)
18
+ except ValueError:
19
+ console.print(f"[red]Invalid version: '{version_str}'. Use 'v3' or '3'.[/red]")
20
+ raise SystemExit(1)
21
+
22
+
23
+ @click.command()
24
+ @click.argument("version")
25
+ @click.argument("connector_name", required=False, default=None)
26
+ def rollback(version: str, connector_name: str | None):
27
+ """Restore working state from a past revision.
28
+
29
+ This overwrites the current working state with the specified revision.
30
+ Does NOT auto-publish. Run 'plugsync push' to publish the restored state.
31
+
32
+ Examples:
33
+ plugsync rollback v3
34
+ plugsync rollback 3
35
+ plugsync rollback v3 hubspot-juve
36
+ """
37
+ try:
38
+ client = PlugSyncClient()
39
+ except RuntimeError as e:
40
+ console.print(f"[red]{e}[/red]")
41
+ raise SystemExit(1)
42
+
43
+ ver = _parse_version(version)
44
+
45
+ # Resolve connector_id
46
+ connector_id = None
47
+ if connector_name:
48
+ connector = client.find_connector(connector_name)
49
+ if not connector:
50
+ console.print(f"[red]Connector '{connector_name}' not found[/red]")
51
+ raise SystemExit(1)
52
+ connector_id = connector["id"]
53
+ else:
54
+ local_config = Path.cwd() / ".plugsync.yaml"
55
+ if local_config.exists():
56
+ with open(local_config) as f:
57
+ data = yaml.safe_load(f) or {}
58
+ connector_id = data.get("connector_id")
59
+
60
+ if not connector_id:
61
+ console.print("[red]Specify a connector name or run from a connector directory.[/red]")
62
+ raise SystemExit(1)
63
+
64
+ console.print(f"Restoring to v{ver}...")
65
+ result = client.restore_revision(connector_id, ver)
66
+
67
+ if result.get("restored"):
68
+ console.print(f"[green]Working state restored to v{ver}[/green]")
69
+ console.print(f"Run [bold]plugsync push[/bold] to publish, or [bold]plugsync pull[/bold] to update local files.")
70
+ else:
71
+ console.print(f"[red]Failed to restore v{ver}[/red]")
72
+ raise SystemExit(1)
@@ -0,0 +1,66 @@
1
+ """plugsync validate — validate current connector state."""
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import yaml
6
+ from rich.console import Console
7
+
8
+ from plugsync_cli.client import PlugSyncClient
9
+ from plugsync_cli.serializer import read_directory_to_zip
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.command()
15
+ @click.argument("path", required=False, default=None)
16
+ def validate(path: str | None):
17
+ """Validate the connector configuration.
18
+
19
+ Uploads the local state to the server and runs validation
20
+ (schema, syntax, referential integrity).
21
+
22
+ Examples:
23
+ plugsync validate
24
+ plugsync validate ./hubspot-juve
25
+ """
26
+ try:
27
+ client = PlugSyncClient()
28
+ except RuntimeError as e:
29
+ console.print(f"[red]{e}[/red]")
30
+ raise SystemExit(1)
31
+
32
+ connector_dir = Path(path) if path else Path.cwd()
33
+ if not (connector_dir / "plugsync.yaml").exists():
34
+ console.print("[red]No plugsync.yaml found.[/red]")
35
+ raise SystemExit(1)
36
+
37
+ local_config = connector_dir / ".plugsync.yaml"
38
+ if not local_config.exists():
39
+ console.print("[red]No .plugsync.yaml found. Run 'plugsync pull' first.[/red]")
40
+ raise SystemExit(1)
41
+
42
+ with open(local_config) as f:
43
+ data = yaml.safe_load(f) or {}
44
+ connector_id = data.get("connector_id")
45
+
46
+ # Upload current state (required for server-side validation)
47
+ zip_data = read_directory_to_zip(connector_dir)
48
+ click.confirm(
49
+ "This will upload your local files to the remote working state. Continue?",
50
+ abort=True,
51
+ )
52
+ client.import_zip(connector_id, zip_data)
53
+
54
+ # Validate
55
+ result = client.validate(connector_id)
56
+
57
+ if result["valid"]:
58
+ console.print("[green]Validation passed[/green]")
59
+ return
60
+
61
+ console.print("[red]Validation failed:[/red]")
62
+ for err in result.get("errors", []):
63
+ loc = err.get("location", "")
64
+ msg = err.get("message", "")
65
+ console.print(f" [red]{loc}: {msg}[/red]")
66
+ raise SystemExit(1)
plugsync_cli/compat.py ADDED
@@ -0,0 +1,73 @@
1
+ """API version compatibility check (issue #957).
2
+
3
+ The CLI ships on its own semver line, independent of the backend release
4
+ train. This module declares the minimum backend API version the current
5
+ CLI build expects, and checks it against the API's version endpoint
6
+ before any command that talks to the API runs.
7
+
8
+ As of this writing the backend does not expose that version endpoint yet
9
+ (tracked in issue #992) -- until it ships, every check degrades
10
+ gracefully to a silent no-op rather than a hard failure: an
11
+ absent/unreachable endpoint is evidence of an API that hasn't been
12
+ instrumented yet, not of one that's incompatible.
13
+ """
14
+ import httpx
15
+ from rich.console import Console
16
+
17
+ # Minimum backend API version this CLI build expects. Bump when a CLI
18
+ # release starts depending on an API contract older backends don't have.
19
+ MIN_API_VERSION = "1.0.0"
20
+
21
+ console = Console()
22
+
23
+
24
+ def _parse_version(value: str) -> tuple[int, ...] | None:
25
+ """Parse a dotted numeric version ("1.4.2") into a tuple, or None if unparseable."""
26
+ try:
27
+ return tuple(int(part) for part in value.strip().split("."))
28
+ except (ValueError, AttributeError):
29
+ return None
30
+
31
+
32
+ def check_api_compat(api_url: str, min_version: str = MIN_API_VERSION) -> None:
33
+ """Warn if the API at ``api_url`` reports a version older than ``min_version``.
34
+
35
+ Never raises: any failure to determine the API's version (missing
36
+ endpoint, unreachable host, malformed response, unparseable version
37
+ string) is treated as "unknown" and silently skipped, so a backend
38
+ without the version endpoint is no worse off than one with it.
39
+ """
40
+ try:
41
+ response = httpx.get(f"{api_url}/api/version", timeout=5.0)
42
+ except httpx.RequestError:
43
+ return
44
+
45
+ if response.status_code == 404:
46
+ return
47
+
48
+ try:
49
+ response.raise_for_status()
50
+ api_version = response.json()["api_version"]
51
+ except Exception:
52
+ return
53
+
54
+ current = _parse_version(api_version)
55
+ minimum = _parse_version(min_version)
56
+ if current is None or minimum is None:
57
+ return
58
+
59
+ # Pad to equal length before comparing: tuple comparison in Python
60
+ # compares element-wise and falls back to length when one is a prefix
61
+ # of the other, so (1, 0) < (1, 0, 0) is True even though "1.0" and
62
+ # "1.0.0" are the same version.
63
+ width = max(len(current), len(minimum))
64
+ current_padded = current + (0,) * (width - len(current))
65
+ minimum_padded = minimum + (0,) * (width - len(minimum))
66
+
67
+ if current_padded < minimum_padded:
68
+ console.print(
69
+ f"[yellow]Warning: API at {api_url} reports version {api_version}, "
70
+ f"below the minimum {min_version} this CLI expects. Some commands "
71
+ "may not work correctly. Upgrade the API, or install a matching "
72
+ "plugsync-cli version.[/yellow]"
73
+ )
plugsync_cli/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Manage ~/.plugsync/config.yaml for CLI authentication and settings."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ CONFIG_DIR = Path.home() / ".plugsync"
8
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
9
+
10
+
11
+ def get_config() -> dict:
12
+ """Load config from ~/.plugsync/config.yaml."""
13
+ if not CONFIG_FILE.exists():
14
+ return {}
15
+ with open(CONFIG_FILE) as f:
16
+ return yaml.safe_load(f) or {}
17
+
18
+
19
+ def save_config(config: dict) -> None:
20
+ """Save config to ~/.plugsync/config.yaml."""
21
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
22
+ with open(CONFIG_FILE, "w") as f:
23
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
24
+
25
+
26
+ def get_api_url() -> str:
27
+ """Get the API base URL from config or environment."""
28
+ env_url = os.environ.get("PLUGSYNC_API_URL")
29
+ if env_url:
30
+ return env_url.rstrip("/")
31
+ config = get_config()
32
+ return config.get("api_url", "http://localhost:8013").rstrip("/")
33
+
34
+
35
+ def get_api_key() -> str | None:
36
+ """Get the org API key from config or environment."""
37
+ env_key = os.environ.get("PLUGSYNC_API_KEY")
38
+ if env_key:
39
+ return env_key
40
+ config = get_config()
41
+ return config.get("api_key")
42
+
43
+
44
+ def get_default_connector() -> str | None:
45
+ """Get the default connector name from local .plugsync.yaml in cwd."""
46
+ local_config = Path.cwd() / ".plugsync.yaml"
47
+ if local_config.exists():
48
+ with open(local_config) as f:
49
+ data = yaml.safe_load(f) or {}
50
+ return data.get("connector")
51
+ return None
plugsync_cli/main.py ADDED
@@ -0,0 +1,84 @@
1
+ """PluSync CLI entry point — git-like connector management."""
2
+ import click
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+
8
+ @click.group()
9
+ @click.version_option(package_name="plugsync-cli")
10
+ def cli():
11
+ """PluSync CLI — manage connectors like code."""
12
+ pass
13
+
14
+
15
+ @cli.group()
16
+ def config():
17
+ """Manage CLI configuration."""
18
+ pass
19
+
20
+
21
+ @config.command("set")
22
+ @click.argument("key")
23
+ @click.argument("value")
24
+ def config_set(key: str, value: str):
25
+ """Set a configuration value (api_url, api_key)."""
26
+ from plugsync_cli.config import get_config, save_config
27
+
28
+ cfg = get_config()
29
+ cfg[key] = value
30
+ save_config(cfg)
31
+ console.print(f"[green]Set {key}[/green]")
32
+
33
+
34
+ @config.command("get")
35
+ @click.argument("key")
36
+ def config_get(key: str):
37
+ """Get a configuration value."""
38
+ from plugsync_cli.config import get_config
39
+
40
+ cfg = get_config()
41
+ value = cfg.get(key)
42
+ if value:
43
+ console.print(value)
44
+ else:
45
+ console.print(f"[yellow]{key} not set[/yellow]")
46
+
47
+
48
+ @config.command("show")
49
+ def config_show():
50
+ """Show all configuration values."""
51
+ from plugsync_cli.config import get_config
52
+
53
+ cfg = get_config()
54
+ if not cfg:
55
+ console.print("[yellow]No configuration set. Run 'plugsync config set api_key <key>'[/yellow]")
56
+ return
57
+ for key, value in cfg.items():
58
+ if key == "api_key":
59
+ # Mask the key
60
+ display = value[:8] + "..." if len(value) > 8 else "***"
61
+ console.print(f" {key}: {display}")
62
+ else:
63
+ console.print(f" {key}: {value}")
64
+
65
+
66
+ from plugsync_cli.commands.pull import pull
67
+ from plugsync_cli.commands.push import push
68
+ from plugsync_cli.commands.diff import diff
69
+ from plugsync_cli.commands.validate import validate
70
+ from plugsync_cli.commands.preview import preview
71
+ from plugsync_cli.commands.log import log_cmd
72
+ from plugsync_cli.commands.rollback import rollback
73
+ from plugsync_cli.commands.plugin import plugin
74
+ from plugsync_cli.commands.auth import auth
75
+
76
+ cli.add_command(pull)
77
+ cli.add_command(push)
78
+ cli.add_command(diff)
79
+ cli.add_command(validate)
80
+ cli.add_command(preview)
81
+ cli.add_command(log_cmd)
82
+ cli.add_command(rollback)
83
+ cli.add_command(plugin)
84
+ cli.add_command(auth)