Skip to content

Commit b02760a

Browse files
Run local server via stacksync dev command
1 parent 732f1b2 commit b02760a

5 files changed

Lines changed: 200 additions & 2 deletions

File tree

cli/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ dependencies = [
1414
"click>=8.1.7",
1515
"questionary>=2.1.1",
1616
"websocket-client>=1.9.0",
17-
"PyYAML"
17+
"PyYAML",
18+
"Flask>=3.1.3"
1819
]
1920

2021
[project.scripts]

cli/src/stacksync_cli/commands/dev.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import os
2+
import shutil
3+
import subprocess
4+
import sys
15
from ..utils import with_auth, with_stacksync_yml
26
import click
37

@@ -11,4 +15,51 @@ def dev(api_key: str, stacksync_yml: dict) -> None:
1115
runs a Stacksync application on localhost,
1216
and uses the local bridge to route traffic to the application.
1317
"""
14-
print("Oh boy", api_key, stacksync_yml)
18+
click.echo("Creating build folder...")
19+
build_dir = _create_build_folder(stacksync_yml)
20+
click.echo("Build folder created.")
21+
click.echo("Starting application on http://127.0.0.1:2323 (Ctrl+C to stop)")
22+
_start_application(build_dir)
23+
24+
25+
def _create_build_folder(stacksync_yml: dict) -> str:
26+
"""
27+
Generates a .stacksync_build folder in the current working directory.
28+
"""
29+
build_dir = os.path.join(os.getcwd(), ".stacksync_build")
30+
os.makedirs(build_dir, exist_ok=True)
31+
32+
# Copy current working directory to the build directory.
33+
shutil.copytree(os.getcwd(), build_dir, dirs_exist_ok=True)
34+
35+
# Inject the app_router.py file as main.py into the build directory.
36+
app_router_path = os.path.join(os.path.dirname(__file__), "..", "generator_files", "app_router.py")
37+
shutil.copy(app_router_path, os.path.join(build_dir, "main.py"))
38+
39+
return build_dir
40+
41+
42+
def _start_application(build_dir: str) -> None:
43+
"""
44+
Run Flask in the build directory without changing the CLI process cwd.
45+
46+
Uses the same interpreter as the CLI (``python -m flask``) and ``--app main``
47+
so the injected ``main.py`` (``app`` instance) is found.
48+
"""
49+
subprocess.run(
50+
[
51+
sys.executable,
52+
"-m",
53+
"flask",
54+
"--app",
55+
"main",
56+
"run",
57+
"--host",
58+
"127.0.0.1",
59+
"--port",
60+
"2323",
61+
],
62+
cwd=build_dir,
63+
check=True,
64+
)
65+
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
App router for Stacksync Apps defined via stacksync.yml files.
3+
This file is NOT intended to be used directly. It is injected into the build output folder
4+
when users run `stacksync dev` or `stacksync deploy`.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import importlib.util
10+
import logging
11+
import os
12+
from typing import Any
13+
14+
import yaml
15+
from flask import Flask, current_app, jsonify, request
16+
17+
# Paths are relative to the build output root (e.g. .stacksync_build)
18+
ROOT = os.path.dirname(os.path.abspath(__file__))
19+
MODULES_DIR = os.path.join(ROOT, "modules")
20+
21+
22+
23+
def load_stacksync_yml() -> dict[str, Any]:
24+
path = os.path.join(ROOT, "stacksync.yml")
25+
with open(path, "r", encoding="utf-8") as f:
26+
return yaml.safe_load(f) or {}
27+
28+
29+
def import_module_from_path(module_name: str, file_path: str):
30+
"""Load modules/acme_crm_create_record/schema.py as a distinct Python module."""
31+
spec = importlib.util.spec_from_file_location(module_name, file_path)
32+
if spec is None or spec.loader is None:
33+
raise ImportError(f"Cannot load {file_path}")
34+
mod = importlib.util.module_from_spec(spec)
35+
spec.loader.exec_module(mod)
36+
return mod
37+
38+
39+
def create_app() -> Flask:
40+
if not logging.root.handlers:
41+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
42+
43+
app = Flask(__name__)
44+
app.logger.info("Initializing connector (root=%s)", ROOT)
45+
46+
config = load_stacksync_yml()
47+
app.config["STACKSYNC_YML"] = config
48+
49+
app_settings = config.get("app_settings") or {}
50+
app_name = app_settings.get("app_name", "(unnamed)")
51+
app_type = app_settings.get("app_type", "(unknown)")
52+
app.logger.info("Loaded stacksync.yml: app_name=%r app_type=%r", app_name, app_type)
53+
54+
modules_cfg: dict[str, Any] = config.get("modules") or {}
55+
if not modules_cfg:
56+
app.logger.warning("No modules declared under stacksync.yml 'modules'")
57+
58+
for module_id, module_meta in modules_cfg.items():
59+
package_dir = os.path.join(MODULES_DIR, module_id)
60+
if not os.path.isdir(package_dir):
61+
app.logger.warning("Skipping module %r: no directory at %s", module_id, package_dir)
62+
continue
63+
64+
prefix = f"/modules/{module_id}"
65+
registered: list[str] = []
66+
67+
# schema.py → schema_handler(form_data, credentials)
68+
schema_path = os.path.join(package_dir, "schema.py")
69+
if os.path.isfile(schema_path):
70+
smod = import_module_from_path(f"{module_id}_schema", schema_path)
71+
72+
@app.route(f"{prefix}/schema", methods=["GET", "POST"])
73+
def schema_route(
74+
_smod=smod,
75+
_meta=module_meta,
76+
_module_id=module_id,
77+
):
78+
current_app.logger.info("Schema handler called (module=%s)", _module_id)
79+
payload = request.get_json(silent=True) or {}
80+
form_data = payload.get("form_data") or {}
81+
credentials = payload.get("credentials")
82+
result = _smod.schema_handler(form_data, credentials)
83+
# schema_handler may return a Schema (dict subclass) or plain dict
84+
return jsonify({"schema": dict(result) if hasattr(result, "keys") else result})
85+
86+
registered.append("schema")
87+
88+
# content.py → content_handler(form_data, credentials, content_object_names)
89+
content_path = os.path.join(package_dir, "content.py")
90+
if os.path.isfile(content_path):
91+
cmod = import_module_from_path(f"{module_id}_content", content_path)
92+
93+
@app.route(f"{prefix}/content", methods=["POST"])
94+
def content_route(_cmod=cmod, _module_id=module_id):
95+
current_app.logger.info("Content handler called (module=%s)", _module_id)
96+
payload = request.get_json(silent=True) or {}
97+
form_data = payload.get("form_data") or {}
98+
credentials = payload.get("credentials")
99+
names = payload.get("content_object_names") or []
100+
result = _cmod.content_handler(form_data, credentials, names)
101+
return jsonify(result)
102+
103+
registered.append("content")
104+
105+
# execute.py → execute_handler(input, credentials)
106+
execute_path = os.path.join(package_dir, "execute.py")
107+
if os.path.isfile(execute_path):
108+
emod = import_module_from_path(f"{module_id}_execute", execute_path)
109+
110+
@app.route(f"{prefix}/execute", methods=["POST"])
111+
def execute_route(_emod=emod, _module_id=module_id):
112+
current_app.logger.info("Execute handler called (module=%s)", _module_id)
113+
payload = request.get_json(silent=True) or {}
114+
data = payload.get("data") or {}
115+
credentials = payload.get("credentials") or {}
116+
result = _emod.execute_handler(data, credentials)
117+
return jsonify(result)
118+
119+
registered.append("execute")
120+
121+
if registered:
122+
app.logger.info(
123+
"Registered module %r: %s",
124+
module_id,
125+
", ".join(registered),
126+
)
127+
else:
128+
app.logger.warning(
129+
"Module %r: no schema.py, content.py, or execute.py found",
130+
module_id,
131+
)
132+
133+
@app.get("/health")
134+
def health():
135+
return jsonify({"status": "ok"})
136+
137+
app.logger.info("Routes ready (includes GET /health)")
138+
return app
139+
140+
141+
app = create_app()
142+
143+
if __name__ == "__main__":
144+
app.run(host="127.0.0.1", port=5000, debug=True)

cli/src/stacksync_cli/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def with_stacksync_yml(func):
137137
@wraps(func)
138138
def wrapper(*args, **kwargs):
139139
# Get current directory
140+
click.echo("Current directory: " + os.getcwd())
140141
current_dir = os.getcwd()
141142
stacksync_yml_path = os.path.join(current_dir, "stacksync.yml")
142143
if not os.path.exists(stacksync_yml_path):

templates/connector/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.stacksync_build/

0 commit comments

Comments
 (0)