From a7b8986701f2c988693d0fe429f65cefb9615e20 Mon Sep 17 00:00:00 2001 From: Matthew Bernardo Date: Fri, 17 Apr 2026 01:18:58 +0200 Subject: [PATCH 1/3] Modify cert handling to fix git download --- cli/pyproject.toml | 1 + cli/src/stacksync_cli/commands/create.py | 7 ++++++- cli/stacksync.spec | 6 ++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 7dc01fd..c1ccc2d 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "UNLICENSED" } dependencies = [ + "certifi>=2024.2.2", "click>=8.1.7", "questionary>=2.1.1", "websocket-client>=1.9.0", diff --git a/cli/src/stacksync_cli/commands/create.py b/cli/src/stacksync_cli/commands/create.py index 5b826b5..0e204f2 100644 --- a/cli/src/stacksync_cli/commands/create.py +++ b/cli/src/stacksync_cli/commands/create.py @@ -1,10 +1,12 @@ import os import re import shutil +import ssl import tempfile import urllib.request import zipfile +import certifi import click from ..utils import with_auth @@ -52,7 +54,10 @@ def _get_templates_folder() -> str: temp_folder = tempfile.mkdtemp() zip_url = "https://github.com/stacksyncdata/cdk/archive/refs/heads/prod.zip" zip_path = os.path.join(temp_folder, "cdk.zip") - with urllib.request.urlopen(zip_url) as response, open(zip_path, "wb") as out_file: + ssl_context = ssl.create_default_context(cafile=certifi.where()) + with urllib.request.urlopen(zip_url, context=ssl_context) as response, open( + zip_path, "wb" + ) as out_file: shutil.copyfileobj(response, out_file) with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(temp_folder) diff --git a/cli/stacksync.spec b/cli/stacksync.spec index 0c79148..eff78a3 100644 --- a/cli/stacksync.spec +++ b/cli/stacksync.spec @@ -1,16 +1,18 @@ -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules # questionary pulls in prompt_toolkit modules dynamically, so collect them # explicitly to keep the standalone binary interactive on all platforms. hiddenimports = collect_submodules("questionary") +# PyInstaller onefile builds do not ship the OS CA store; bundle certifi's PEM. +datas = collect_data_files("certifi") a = Analysis( ["src/stacksync_cli/__main__.py"], pathex=["src"], binaries=[], - datas=[], + datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, From 732f1b210f8542653411da4fd5ee5b865131a35f Mon Sep 17 00:00:00 2001 From: Matthew Bernardo Date: Fri, 17 Apr 2026 20:22:51 +0200 Subject: [PATCH 2/3] Improve connector template --- lib/pyproject.toml | 3 + lib/stacksync_cdk/__init__.py | 4 +- lib/stacksync_cdk/api_client.py | 45 +++-- lib/stacksync_cdk/schema_utils.py | 157 +++++++++++++++++- templates/connector/README.md | 3 +- templates/connector/api_client.py | 86 ++++++++-- .../base_schema.yml | 6 +- .../modules/acme_crm_create_record/content.py | 44 +++++ .../context.md | 0 .../modules/acme_crm_create_record/execute.py | 10 ++ .../schema.py | 7 +- .../dummy_crm_create_record/content.py | 0 .../dummy_crm_create_record/execute.py | 0 templates/connector/stacksync.yml | 19 +++ 14 files changed, 345 insertions(+), 39 deletions(-) rename templates/connector/modules/{dummy_crm_create_record => acme_crm_create_record}/base_schema.yml (86%) create mode 100644 templates/connector/modules/acme_crm_create_record/content.py rename templates/connector/modules/{dummy_crm_create_record => acme_crm_create_record}/context.md (100%) create mode 100644 templates/connector/modules/acme_crm_create_record/execute.py rename templates/connector/modules/{dummy_crm_create_record => acme_crm_create_record}/schema.py (72%) delete mode 100644 templates/connector/modules/dummy_crm_create_record/content.py delete mode 100644 templates/connector/modules/dummy_crm_create_record/execute.py diff --git a/lib/pyproject.toml b/lib/pyproject.toml index 0dccf40..4ca3086 100644 --- a/lib/pyproject.toml +++ b/lib/pyproject.toml @@ -8,6 +8,9 @@ version = "0.1.0" description = "Stacksync CDK library for connector modules (ApiClient, schema utilities)" requires-python = ">=3.10" license = { text = "UNLICENSED" } +dependencies = [ + "PyYAML>=6.0", +] [tool.hatch.build.targets.wheel] packages = ["stacksync_cdk"] diff --git a/lib/stacksync_cdk/__init__.py b/lib/stacksync_cdk/__init__.py index 48c4462..87b5d5f 100644 --- a/lib/stacksync_cdk/__init__.py +++ b/lib/stacksync_cdk/__init__.py @@ -1,6 +1,6 @@ """Stub implementation of stacksync_cdk for local development and template resolution.""" from stacksync_cdk.api_client import ApiClient -from stacksync_cdk.schema_utils import load_schema, to_schema +from stacksync_cdk.schema_utils import Schema, load_schema, to_schema, validate_schema_dict -__all__ = ["ApiClient", "load_schema", "to_schema"] +__all__ = ["ApiClient", "Schema", "load_schema", "to_schema", "validate_schema_dict"] diff --git a/lib/stacksync_cdk/api_client.py b/lib/stacksync_cdk/api_client.py index a2c7f77..1893755 100644 --- a/lib/stacksync_cdk/api_client.py +++ b/lib/stacksync_cdk/api_client.py @@ -1,11 +1,17 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, TypeVar +import functools +import logging from abc import abstractmethod +from collections.abc import Callable +from typing import Any, TypeVar, cast + +from stacksync_cdk.schema_utils import validate_schema_dict F = TypeVar("F", bound=Callable[..., Any]) +_logger = logging.getLogger(__name__) + class ApiClient: """ @@ -40,20 +46,33 @@ def _authenticate(self) -> None: raise ValueError("No credentials provided") self.authenticate() - @classmethod - def as_schema(cls) -> Callable[[F], F]: - """Decorator that validates return values as Stacksync schema (stub).""" - - def decorator(fn: F) -> F: - return fn - - return decorator - @classmethod def returns_schema(cls) -> Callable[[F], F]: - """Decorator that validates return values as Stacksync schema (stub).""" + """ + Decorator for methods that return a Stacksync module schema dict or fragment. + + After the wrapped method runs, the return value is validated with + :func:`~stacksync_cdk.schema_utils.validate_schema_dict`. If validation fails, + an error is logged (with the method name) and the exception is re-raised. + """ def decorator(fn: F) -> F: - return fn + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + result = fn(*args, **kwargs) + label = f"{fn.__module__}.{fn.__qualname__}" + try: + validate_schema_dict(result, source=label) + except (TypeError, ValueError) as e: + _logger.error( + "Invalid Stacksync schema returned from %s: %s", + label, + e, + ) + raise + return result + + return cast(F, wrapper) return decorator + diff --git a/lib/stacksync_cdk/schema_utils.py b/lib/stacksync_cdk/schema_utils.py index aa0ff85..b8e3b86 100644 --- a/lib/stacksync_cdk/schema_utils.py +++ b/lib/stacksync_cdk/schema_utils.py @@ -1,12 +1,157 @@ -def _stub() -> None: - raise NotImplementedError("stacksync_cdk is not implemented in this repository stub") +from __future__ import annotations +import inspect +import os +from pathlib import Path +from typing import Any -def load_schema(*args, **kwargs): - """Load a YAML schema file into a Stacksync schema object.""" - _stub() +import yaml + + +class Schema(dict): + """ + Stacksync module schema as a dict with helpers to merge API-driven fragments. + + Returned by :func:`load_schema` so handlers can call ``schema.add(...)`` and + return the same object (subclass of ``dict``, JSON-serializable). + """ + + def add(self, fragment: dict[str, Any] | None) -> None: + """ + Merge a schema fragment (typically ``{"fields": [...]}`` from ``to_schema``) + into this schema: extends ``fields`` and appends new field ids to + ``ui_options.ui_order`` when present. + """ + if not fragment: + return + + new_fields = fragment.get("fields") + if isinstance(new_fields, list) and new_fields: + self.setdefault("fields", []).extend(new_fields) + uo = self.setdefault("ui_options", {}) + order = uo.setdefault("ui_order", []) + for field in new_fields: + if not isinstance(field, dict): + continue + fid = field.get("id") + if fid and fid not in order: + order.append(fid) + + frag_ui = fragment.get("ui_options") + if isinstance(frag_ui, dict): + extra_order = frag_ui.get("ui_order") + if isinstance(extra_order, list): + uo = self.setdefault("ui_options", {}) + order = uo.setdefault("ui_order", []) + for fid in extra_order: + if fid not in order: + order.append(fid) + + +def load_schema( + filename: str, + *, + relative_to: str | os.PathLike[str] | None = None, +) -> Schema: + """ + Load a YAML module schema file from the same directory as the calling module. + + Typical usage from ``schema.py`` next to ``base_schema.yml``:: + + schema = load_schema("base_schema.yml") + + Parameters + ---------- + filename: + Name or path relative to the caller's directory (e.g. ``base_schema.yml``). + relative_to: + Directory used to resolve ``filename``. Defaults to the file containing + the *caller* of ``load_schema`` (the frame above this function). + """ + if relative_to is None: + stack = inspect.stack() + if len(stack) < 2: + raise RuntimeError("load_schema: could not determine caller file path") + caller = stack[1] + relative_to = caller.filename + del stack + + fn = Path(filename) + if fn.is_absolute(): + raise ValueError("load_schema: filename must be a relative path") + + base = Path(relative_to).resolve() + base_dir = base if base.is_dir() else base.parent + base_resolved = base_dir.resolve() + path = (base_dir / filename).resolve() + if not path.is_relative_to(base_resolved): + raise ValueError(f"load_schema: path escapes module directory: {filename}") + + with path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if data is None: + data = {} + if not isinstance(data, dict): + raise TypeError(f"load_schema: root of {path} must be a mapping, got {type(data).__name__}") + + return Schema(data) + + +def validate_schema_dict(value: Any, *, source: str = "method") -> dict[str, Any]: + """ + Check that *value* is a Stacksync module schema dict or a mergeable fragment + (at least a ``fields`` list). Used by :meth:`ApiClient.as_schema`. + + Raises ``TypeError`` or ``ValueError`` with messages intended for connector authors. + """ + if isinstance(value, Schema): + data: dict[str, Any] = dict(value) + elif isinstance(value, dict): + data = value + else: + raise TypeError( + f"{source}: expected a dict or Schema for Stacksync schema data, got {type(value).__name__}" + ) + + if "fields" not in data: + raise ValueError( + f"{source}: Stacksync schema must include top-level 'fields' (list of field definitions). " + "See https://docs.stacksync.com/two-way-sync/developers/module-schema-reference" + ) + + fields = data["fields"] + if not isinstance(fields, list): + raise ValueError(f"{source}: 'fields' must be a list, not {type(fields).__name__}") + + for i, field in enumerate(fields): + if not isinstance(field, dict): + raise ValueError( + f"{source}: fields[{i}] must be a dict, got {type(field).__name__}" + ) + if "id" not in field: + raise ValueError(f"{source}: fields[{i}] is missing required 'id'") + if "type" not in field: + raise ValueError(f"{source}: fields[{i}] ({field.get('id')!r}) is missing required 'type'") + + if "metadata" in data: + meta = data["metadata"] + if not isinstance(meta, dict): + raise ValueError(f"{source}: 'metadata' must be a dict, not {type(meta).__name__}") + if "workflows_module_schema_version" not in meta: + raise ValueError( + f"{source}: when 'metadata' is present it must include 'workflows_module_schema_version' " + "(see module schema reference)" + ) + ver = meta["workflows_module_schema_version"] + if not isinstance(ver, str): + raise ValueError( + f"{source}: metadata.workflows_module_schema_version must be a string, not {type(ver).__name__}" + ) + + return data def to_schema(*args, **kwargs): """Convert arbitrary data into a Stacksync schema dict.""" - _stub() + raise NotImplementedError("to_schema is not implemented in this repository stub") diff --git a/templates/connector/README.md b/templates/connector/README.md index 5e72611..305a05a 100644 --- a/templates/connector/README.md +++ b/templates/connector/README.md @@ -11,6 +11,7 @@ This application should be managed via the Stacksync CLI. Please refer to the do Once the CLI is configured, run ${APP_NAME} locally using: ```bash +# In your terminal in the same folder as your stacksync.yml file stacksync dev ``` @@ -20,7 +21,7 @@ To deploy: stacksync deploy ``` -Add additional modules to your project using: +Add modules to your project using: ```bash stacksync create module --name "[Module name]" --url "[url with API documentation]" --prompt "[Description of your module's functionality]" diff --git a/templates/connector/api_client.py b/templates/connector/api_client.py index eabd486..e6852d2 100644 --- a/templates/connector/api_client.py +++ b/templates/connector/api_client.py @@ -1,21 +1,83 @@ import requests from stacksync_cdk.api_client import ApiClient -from stacksync_cdk.schema_utils import to_schema -class DummyCrmApiClient(ApiClient): +class AcmeCrmApiClient(ApiClient): def __init__(self, credentials: dict): super().__init__(credentials) - # The @ApiClient.returns_schema() decorator validates that the return - # data is a valid Stacksync schema dict and logs instructive errors if it's not. - @ApiClient.as_schema() - def get_additional_fields(self, record_type: str): + def authenticate(self) -> None: + """Fictional ACME CRM: authentication is handled via `headers` on the base client.""" + + def get_record_types(self) -> list[dict[str, str]]: + """ + Return SelectWidget choices for ACME CRM record types. + + A real connector would GET a catalog endpoint (for example + ``/api/v1/record-types``). This template uses a fixed fictional list so + local development does not depend on a live host. + """ + return [ + {"value": "contact", "label": "Contact"}, + {"value": "company", "label": "Company"}, + {"value": "deal", "label": "Deal"}, + {"value": "task", "label": "Task"}, + {"value": "note", "label": "Note"}, + ] + + # @ApiClient.returns_schema() validates the return value with validate_schema_dict and + # logs on failure; it does not transform the payload. + @ApiClient.returns_schema() + def get_additional_fields(self, record_type: str) -> dict: + """ + Return extra field definitions for the chosen record type (a schema fragment with + a top-level ``fields`` list). + + A real connector would GET metadata from the CRM (for example + ``/api/v1/fields/{record_type}``) and map the response into module fields. This + template returns a fixed fictional fragment so local development does not depend + on a live host. See https://docs.stacksync.com/two-way-sync/developers/module-schema-reference + """ if not self.credentials: raise ValueError("No credentials provided") - # Let's pretend there's an API endpoint for fetching fields from the CRM - url = f"https://dummycrm.com/api/v1/fields/{record_type}" - response = requests.get(url, headers=self.headers) - # The to_schema() function converts a dict into a Stacksync schema dict. - # It's takes a very generic/naive approach, so pre or post processing may be needed. - return to_schema(response.json()) + return { + "fields": [ + { + "id": "status", + "type": "string", + "label": "Status", + "description": "Lifecycle state for this record.", + "choices": { + "values": [ + {"value": "open", "label": "Open"}, + {"value": "in_progress", "label": "In progress"}, + {"value": "closed", "label": "Closed"}, + ] + }, + "ui_options": {"ui_widget": "SelectWidget"}, + }, + { + "id": "owner_email", + "type": "string", + "label": "Owner email", + "format": "email", + "validation": {"required": True}, + "ui_options": {"ui_widget": "input"}, + }, + { + "id": "internal_notes", + "type": "string", + "label": "Internal notes", + "description": f"Optional notes for this {record_type} record (not visible to customers).", + "ui_options": {"ui_widget": "textarea"}, + }, + ] + } + + def create_record(self, record_type: str, data: dict) -> dict: + if not self.credentials: + raise ValueError("No credentials provided") + + url = f"https://example.com/acme-crm/api/v1/records/{record_type}" + response = requests.post(url, headers=self.headers, json=data) + return response.json() diff --git a/templates/connector/modules/dummy_crm_create_record/base_schema.yml b/templates/connector/modules/acme_crm_create_record/base_schema.yml similarity index 86% rename from templates/connector/modules/dummy_crm_create_record/base_schema.yml rename to templates/connector/modules/acme_crm_create_record/base_schema.yml index 609759c..ed99f59 100644 --- a/templates/connector/modules/dummy_crm_create_record/base_schema.yml +++ b/templates/connector/modules/acme_crm_create_record/base_schema.yml @@ -1,4 +1,4 @@ -# Base input schema for the dummy CRM "create record" module. +# Base input schema for the fictional ACME CRM "create record" module. # See https://docs.stacksync.com/two-way-sync/developers/module-schema-reference metadata: workflows_module_schema_version: "1.0.0" @@ -26,11 +26,13 @@ fields: ui_options: ui_widget: textarea - - id: type + - id: record_type type: string label: Record type description: >- The type of record to create. + on_action: + load_schema: true choices: values: [] content: diff --git a/templates/connector/modules/acme_crm_create_record/content.py b/templates/connector/modules/acme_crm_create_record/content.py new file mode 100644 index 0000000..85e8913 --- /dev/null +++ b/templates/connector/modules/acme_crm_create_record/content.py @@ -0,0 +1,44 @@ +""" +Dynamic content for fields that declare `content.content_objects` in the module schema. + +See https://docs.stacksync.com/two-way-sync/developers/module-schema-reference (Dynamic Content). + +The Stacksync runtime POSTs to the module `/content` endpoint with `form_data`, +`content_object_names` (ids from the schema), and optional `credentials`. This handler +returns `content_objects` entries keyed by those ids so SelectWidget (and similar +widgets) can populate choices. +""" + +from __future__ import annotations + +from typing import Any + +from api_client import AcmeCrmApiClient + + +def content_handler( + content_object_names: list, + form_data: dict, + credentials: dict | str | None, +) -> dict: + """ + Build dynamic choice lists for the requested content object ids. + """ + # If no credentials, return an empty list. + if not credentials: + return {"content_objects": []} + + content_objects: list[dict[str, Any]] = [] + + # Fetch content for the requested content object names. + for name in content_object_names: + if name == "record_type": + api_client = AcmeCrmApiClient(credentials) + content_objects.extend( + { + "content_object_name": "record_type", + "data": api_client.get_record_types(), + } + ) + + return {"content_objects": content_objects} diff --git a/templates/connector/modules/dummy_crm_create_record/context.md b/templates/connector/modules/acme_crm_create_record/context.md similarity index 100% rename from templates/connector/modules/dummy_crm_create_record/context.md rename to templates/connector/modules/acme_crm_create_record/context.md diff --git a/templates/connector/modules/acme_crm_create_record/execute.py b/templates/connector/modules/acme_crm_create_record/execute.py new file mode 100644 index 0000000..8054bfd --- /dev/null +++ b/templates/connector/modules/acme_crm_create_record/execute.py @@ -0,0 +1,10 @@ +from api_client import AcmeCrmApiClient + + +def execute_handler(input: dict, credentials: dict) -> dict: + """ + Execute the ACME CRM create record module. + """ + api_client = AcmeCrmApiClient(credentials) + result = api_client.create_record(input["record_type"], input["data"]) + return result diff --git a/templates/connector/modules/dummy_crm_create_record/schema.py b/templates/connector/modules/acme_crm_create_record/schema.py similarity index 72% rename from templates/connector/modules/dummy_crm_create_record/schema.py rename to templates/connector/modules/acme_crm_create_record/schema.py index 359829f..863f146 100644 --- a/templates/connector/modules/dummy_crm_create_record/schema.py +++ b/templates/connector/modules/acme_crm_create_record/schema.py @@ -1,4 +1,4 @@ -from cdk.templates.connector import api_client +from api_client import AcmeCrmApiClient from stacksync_cdk.schema_utils import load_schema @@ -7,7 +7,8 @@ def schema_handler(form_data: dict, credentials: str): # If these fields are present, we can fetch additional fields from the API # and add them to the schema so the user can see them in the UI. - if credentials and form_data.get("select_object"): - additional_fields = api_client.get_additional_fields(form_data.get("select_object")) + if credentials and form_data.get("type"): + api_client = AcmeCrmApiClient(credentials) + additional_fields = api_client.get_additional_fields(form_data.get("type")) schema.add(additional_fields) return schema diff --git a/templates/connector/modules/dummy_crm_create_record/content.py b/templates/connector/modules/dummy_crm_create_record/content.py deleted file mode 100644 index e69de29..0000000 diff --git a/templates/connector/modules/dummy_crm_create_record/execute.py b/templates/connector/modules/dummy_crm_create_record/execute.py deleted file mode 100644 index e69de29..0000000 diff --git a/templates/connector/stacksync.yml b/templates/connector/stacksync.yml index e69de29..6bf3f67 100644 --- a/templates/connector/stacksync.yml +++ b/templates/connector/stacksync.yml @@ -0,0 +1,19 @@ +# The Stacksync.yml file is the main configuration file for your connector. +# It defines the app settings and modules for your connector in a centralized location. + +app_settings: + app_type: "acme" # only lowercase letters, numbers, and underscores are allowed, e.g. acme_crm. + app_name: "ACME CRM" + app_icon_svg_url: "https://your-image-hosting-service.com/acme-crm.svg" # this is the icon of the app, should be a URL pointing to a SVG file. + app_description: "A concise description of your app and its capabilities." + requires_credentials_for_schema: true # Indicates whether your app uses per-user + # credentials for schema dynamic schema/content updates. + # Can be set here globally or per module. + +# Define module settings here. The module names (e.g., acme_crm_create_record) +# should match the folder name in the modules directory. +modules: + acme_crm_create_record: + module_name: "Create ACME CRM Record" + module_description: "Create any type of ACME CRM record with dynamic field support" + requires_credentials_for_schema: true \ No newline at end of file From b02760ad5658fc231cedeeabdd88c75b29bf2bf9 Mon Sep 17 00:00:00 2001 From: Matthew Bernardo Date: Fri, 17 Apr 2026 20:50:38 +0200 Subject: [PATCH 3/3] Run local server via stacksync dev command --- cli/pyproject.toml | 3 +- cli/src/stacksync_cli/commands/dev.py | 53 ++++++- .../generator_files/app_router.py | 144 ++++++++++++++++++ cli/src/stacksync_cli/utils.py | 1 + templates/connector/.gitignore | 1 + 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 cli/src/stacksync_cli/generator_files/app_router.py create mode 100644 templates/connector/.gitignore diff --git a/cli/pyproject.toml b/cli/pyproject.toml index c1ccc2d..1667a44 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -14,7 +14,8 @@ dependencies = [ "click>=8.1.7", "questionary>=2.1.1", "websocket-client>=1.9.0", - "PyYAML" + "PyYAML", + "Flask>=3.1.3" ] [project.scripts] diff --git a/cli/src/stacksync_cli/commands/dev.py b/cli/src/stacksync_cli/commands/dev.py index b9270e3..ca15a7d 100644 --- a/cli/src/stacksync_cli/commands/dev.py +++ b/cli/src/stacksync_cli/commands/dev.py @@ -1,3 +1,7 @@ +import os +import shutil +import subprocess +import sys from ..utils import with_auth, with_stacksync_yml import click @@ -11,4 +15,51 @@ def dev(api_key: str, stacksync_yml: dict) -> None: runs a Stacksync application on localhost, and uses the local bridge to route traffic to the application. """ - print("Oh boy", api_key, stacksync_yml) + click.echo("Creating build folder...") + build_dir = _create_build_folder(stacksync_yml) + click.echo("Build folder created.") + click.echo("Starting application on http://127.0.0.1:2323 (Ctrl+C to stop)") + _start_application(build_dir) + + +def _create_build_folder(stacksync_yml: dict) -> str: + """ + Generates a .stacksync_build folder in the current working directory. + """ + build_dir = os.path.join(os.getcwd(), ".stacksync_build") + os.makedirs(build_dir, exist_ok=True) + + # Copy current working directory to the build directory. + shutil.copytree(os.getcwd(), build_dir, dirs_exist_ok=True) + + # Inject the app_router.py file as main.py into the build directory. + app_router_path = os.path.join(os.path.dirname(__file__), "..", "generator_files", "app_router.py") + shutil.copy(app_router_path, os.path.join(build_dir, "main.py")) + + return build_dir + + +def _start_application(build_dir: str) -> None: + """ + Run Flask in the build directory without changing the CLI process cwd. + + Uses the same interpreter as the CLI (``python -m flask``) and ``--app main`` + so the injected ``main.py`` (``app`` instance) is found. + """ + subprocess.run( + [ + sys.executable, + "-m", + "flask", + "--app", + "main", + "run", + "--host", + "127.0.0.1", + "--port", + "2323", + ], + cwd=build_dir, + check=True, + ) + diff --git a/cli/src/stacksync_cli/generator_files/app_router.py b/cli/src/stacksync_cli/generator_files/app_router.py new file mode 100644 index 0000000..36949cb --- /dev/null +++ b/cli/src/stacksync_cli/generator_files/app_router.py @@ -0,0 +1,144 @@ +""" +App router for Stacksync Apps defined via stacksync.yml files. +This file is NOT intended to be used directly. It is injected into the build output folder +when users run `stacksync dev` or `stacksync deploy`. +""" + +from __future__ import annotations + +import importlib.util +import logging +import os +from typing import Any + +import yaml +from flask import Flask, current_app, jsonify, request + +# Paths are relative to the build output root (e.g. .stacksync_build) +ROOT = os.path.dirname(os.path.abspath(__file__)) +MODULES_DIR = os.path.join(ROOT, "modules") + + + +def load_stacksync_yml() -> dict[str, Any]: + path = os.path.join(ROOT, "stacksync.yml") + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def import_module_from_path(module_name: str, file_path: str): + """Load modules/acme_crm_create_record/schema.py as a distinct Python module.""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load {file_path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def create_app() -> Flask: + if not logging.root.handlers: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + + app = Flask(__name__) + app.logger.info("Initializing connector (root=%s)", ROOT) + + config = load_stacksync_yml() + app.config["STACKSYNC_YML"] = config + + app_settings = config.get("app_settings") or {} + app_name = app_settings.get("app_name", "(unnamed)") + app_type = app_settings.get("app_type", "(unknown)") + app.logger.info("Loaded stacksync.yml: app_name=%r app_type=%r", app_name, app_type) + + modules_cfg: dict[str, Any] = config.get("modules") or {} + if not modules_cfg: + app.logger.warning("No modules declared under stacksync.yml 'modules'") + + for module_id, module_meta in modules_cfg.items(): + package_dir = os.path.join(MODULES_DIR, module_id) + if not os.path.isdir(package_dir): + app.logger.warning("Skipping module %r: no directory at %s", module_id, package_dir) + continue + + prefix = f"/modules/{module_id}" + registered: list[str] = [] + + # schema.py → schema_handler(form_data, credentials) + schema_path = os.path.join(package_dir, "schema.py") + if os.path.isfile(schema_path): + smod = import_module_from_path(f"{module_id}_schema", schema_path) + + @app.route(f"{prefix}/schema", methods=["GET", "POST"]) + def schema_route( + _smod=smod, + _meta=module_meta, + _module_id=module_id, + ): + current_app.logger.info("Schema handler called (module=%s)", _module_id) + payload = request.get_json(silent=True) or {} + form_data = payload.get("form_data") or {} + credentials = payload.get("credentials") + result = _smod.schema_handler(form_data, credentials) + # schema_handler may return a Schema (dict subclass) or plain dict + return jsonify({"schema": dict(result) if hasattr(result, "keys") else result}) + + registered.append("schema") + + # content.py → content_handler(form_data, credentials, content_object_names) + content_path = os.path.join(package_dir, "content.py") + if os.path.isfile(content_path): + cmod = import_module_from_path(f"{module_id}_content", content_path) + + @app.route(f"{prefix}/content", methods=["POST"]) + def content_route(_cmod=cmod, _module_id=module_id): + current_app.logger.info("Content handler called (module=%s)", _module_id) + payload = request.get_json(silent=True) or {} + form_data = payload.get("form_data") or {} + credentials = payload.get("credentials") + names = payload.get("content_object_names") or [] + result = _cmod.content_handler(form_data, credentials, names) + return jsonify(result) + + registered.append("content") + + # execute.py → execute_handler(input, credentials) + execute_path = os.path.join(package_dir, "execute.py") + if os.path.isfile(execute_path): + emod = import_module_from_path(f"{module_id}_execute", execute_path) + + @app.route(f"{prefix}/execute", methods=["POST"]) + def execute_route(_emod=emod, _module_id=module_id): + current_app.logger.info("Execute handler called (module=%s)", _module_id) + payload = request.get_json(silent=True) or {} + data = payload.get("data") or {} + credentials = payload.get("credentials") or {} + result = _emod.execute_handler(data, credentials) + return jsonify(result) + + registered.append("execute") + + if registered: + app.logger.info( + "Registered module %r: %s", + module_id, + ", ".join(registered), + ) + else: + app.logger.warning( + "Module %r: no schema.py, content.py, or execute.py found", + module_id, + ) + + @app.get("/health") + def health(): + return jsonify({"status": "ok"}) + + app.logger.info("Routes ready (includes GET /health)") + return app + + +app = create_app() + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5000, debug=True) \ No newline at end of file diff --git a/cli/src/stacksync_cli/utils.py b/cli/src/stacksync_cli/utils.py index ea8b740..a7bd557 100644 --- a/cli/src/stacksync_cli/utils.py +++ b/cli/src/stacksync_cli/utils.py @@ -137,6 +137,7 @@ def with_stacksync_yml(func): @wraps(func) def wrapper(*args, **kwargs): # Get current directory + click.echo("Current directory: " + os.getcwd()) current_dir = os.getcwd() stacksync_yml_path = os.path.join(current_dir, "stacksync.yml") if not os.path.exists(stacksync_yml_path): diff --git a/templates/connector/.gitignore b/templates/connector/.gitignore new file mode 100644 index 0000000..5fbc2fe --- /dev/null +++ b/templates/connector/.gitignore @@ -0,0 +1 @@ +.stacksync_build/ \ No newline at end of file