caraer-cli 0.1.2__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.
- caraer_cli/__init__.py +4 -0
- caraer_cli/api/__init__.py +1 -0
- caraer_cli/api/apps.py +78 -0
- caraer_cli/api/auth.py +53 -0
- caraer_cli/api/client.py +179 -0
- caraer_cli/api/functions.py +72 -0
- caraer_cli/api/installation.py +93 -0
- caraer_cli/api/integration_runtime.py +77 -0
- caraer_cli/api/projects.py +213 -0
- caraer_cli/api/sandboxes.py +29 -0
- caraer_cli/api/webhooks.py +59 -0
- caraer_cli/app_sync.py +697 -0
- caraer_cli/apps_local.py +274 -0
- caraer_cli/commands/__init__.py +1 -0
- caraer_cli/commands/apps.py +2025 -0
- caraer_cli/commands/auth.py +173 -0
- caraer_cli/commands/company.py +68 -0
- caraer_cli/commands/installation.py +182 -0
- caraer_cli/commands/profile.py +239 -0
- caraer_cli/commands/publish.py +40 -0
- caraer_cli/commands/sandbox.py +92 -0
- caraer_cli/commands/skill.py +165 -0
- caraer_cli/commands/webhooks.py +68 -0
- caraer_cli/completion.py +77 -0
- caraer_cli/completion_callbacks.py +162 -0
- caraer_cli/context.py +41 -0
- caraer_cli/errors.py +84 -0
- caraer_cli/formatters/__init__.py +1 -0
- caraer_cli/formatters/output.py +381 -0
- caraer_cli/formatters/timestamps.py +98 -0
- caraer_cli/install_completion.py +38 -0
- caraer_cli/local_app.py +135 -0
- caraer_cli/main.py +104 -0
- caraer_cli/models/__init__.py +1 -0
- caraer_cli/models/api.py +52 -0
- caraer_cli/project/__init__.py +17 -0
- caraer_cli/project/app_bars_sync.py +154 -0
- caraer_cli/project/app_manifest_template.py +68 -0
- caraer_cli/project/inbound_sync.py +192 -0
- caraer_cli/project/json_schemas.py +66 -0
- caraer_cli/project/lifecycle_sync.py +119 -0
- caraer_cli/project/local_dev.py +825 -0
- caraer_cli/project/manifest_edit.py +51 -0
- caraer_cli/project/marketplace_assemble.py +165 -0
- caraer_cli/project/oauth_providers_sync.py +180 -0
- caraer_cli/project/paths.py +102 -0
- caraer_cli/project/pricing_sync.py +81 -0
- caraer_cli/project/push_plan.py +590 -0
- caraer_cli/project/release.py +128 -0
- caraer_cli/project/scaffold.py +537 -0
- caraer_cli/project/schedules_sync.py +170 -0
- caraer_cli/project/schema.py +160 -0
- caraer_cli/project/settings_sync.py +77 -0
- caraer_cli/project/state.py +51 -0
- caraer_cli/project/sync.py +308 -0
- caraer_cli/project/typegen.py +278 -0
- caraer_cli/project/validate_app.py +1215 -0
- caraer_cli/project/webhooks_sync.py +233 -0
- caraer_cli/resolve.py +55 -0
- caraer_cli/schemas/app.caraer.schema.json +175 -0
- caraer_cli/schemas/function.caraer.schema.json +29 -0
- caraer_cli/schemas/inbound.caraer.schema.json +49 -0
- caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
- caraer_cli/schemas/schedule.caraer.schema.json +39 -0
- caraer_cli/schemas/webhook.caraer.schema.json +50 -0
- caraer_cli/skills/.gitkeep +2 -0
- caraer_cli/skills/caraer-apps/SKILL.md +172 -0
- caraer_cli/skills/caraer-apps/reference.md +163 -0
- caraer_cli/state/__init__.py +1 -0
- caraer_cli/state/config.py +148 -0
- caraer_cli/state/session.py +98 -0
- caraer_cli/utils.py +40 -0
- caraer_cli/wizard/__init__.py +9 -0
- caraer_cli/wizard/catalog.py +170 -0
- caraer_cli/wizard/marketplace.py +350 -0
- caraer_cli/wizard/prompts.py +99 -0
- caraer_cli/wizard/public_app.py +519 -0
- caraer_cli/wizard/scope_macros.py +214 -0
- caraer_cli-0.1.2.dist-info/METADATA +266 -0
- caraer_cli-0.1.2.dist-info/RECORD +83 -0
- caraer_cli-0.1.2.dist-info/WHEEL +4 -0
- caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
- caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import runpy
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import unquote, urlparse
|
|
15
|
+
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
|
|
18
|
+
from caraer_cli.project.paths import functions_dir, inbound_dir, schedules_dir
|
|
19
|
+
from caraer_cli.project.schema import ProjectConfig, load_function_manifest
|
|
20
|
+
from caraer_cli.project.sync import list_local_function_names
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_local_function(root: Path, config: ProjectConfig, function_name: str) -> tuple[str, Path]:
|
|
24
|
+
folder = functions_dir(root, config.srcDir) / function_name
|
|
25
|
+
manifest = load_function_manifest(folder / "function.caraer.json")
|
|
26
|
+
entry = folder / manifest.resolved_entry()
|
|
27
|
+
if not entry.is_file():
|
|
28
|
+
raise FileNotFoundError(f"Missing entry file: {entry}")
|
|
29
|
+
return manifest.runtime, entry
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def invoke_local(runtime: str, entry: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
if runtime.startswith("python"):
|
|
34
|
+
return _invoke_python(entry, payload)
|
|
35
|
+
return _invoke_node(entry, payload)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _invoke_python(entry: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
namespace = runpy.run_path(str(entry))
|
|
40
|
+
handler = namespace.get("handler")
|
|
41
|
+
if handler is None:
|
|
42
|
+
raise ValueError(f"No handler() found in {entry}")
|
|
43
|
+
|
|
44
|
+
# Mimic a minimal request object with .get_json()
|
|
45
|
+
class _Req:
|
|
46
|
+
def get_json(self, silent: bool = True): # noqa: ARG002
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
def get_data(self, as_text: bool = False):
|
|
50
|
+
raw = json.dumps(payload)
|
|
51
|
+
return raw if as_text else raw.encode("utf-8")
|
|
52
|
+
|
|
53
|
+
result = handler(_Req())
|
|
54
|
+
if isinstance(result, dict):
|
|
55
|
+
return result
|
|
56
|
+
return {"result": result}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _invoke_node(entry: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
60
|
+
script = f"""
|
|
61
|
+
const mod = require({json.dumps(str(entry))});
|
|
62
|
+
const payload = {json.dumps(payload)};
|
|
63
|
+
// Keep handler console.log off stdout so the CLI can parse the result JSON.
|
|
64
|
+
const _log = console.log.bind(console);
|
|
65
|
+
console.log = (...args) => console.error(...args);
|
|
66
|
+
const res = {{
|
|
67
|
+
statusCode: 200,
|
|
68
|
+
body: null,
|
|
69
|
+
status(code) {{ this.statusCode = code; return this; }},
|
|
70
|
+
json(body) {{ this.body = body; return this; }},
|
|
71
|
+
send(body) {{ this.body = body; return this; }}
|
|
72
|
+
}};
|
|
73
|
+
const req = {{ body: payload, method: 'POST', headers: {{}} }};
|
|
74
|
+
Promise.resolve(mod.handler(req, res)).then(() => {{
|
|
75
|
+
process.stdout.write(JSON.stringify({{ statusCode: res.statusCode, body: res.body }}));
|
|
76
|
+
}}).catch((err) => {{
|
|
77
|
+
console.error(err && err.stack ? err.stack : String(err));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}});
|
|
80
|
+
"""
|
|
81
|
+
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as tmp:
|
|
82
|
+
tmp.write(script)
|
|
83
|
+
tmp_path = tmp.name
|
|
84
|
+
try:
|
|
85
|
+
completed = subprocess.run(
|
|
86
|
+
["node", tmp_path],
|
|
87
|
+
check=False,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
env=os.environ.copy(),
|
|
91
|
+
)
|
|
92
|
+
if completed.returncode != 0:
|
|
93
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
94
|
+
raise RuntimeError(detail or f"node exited with status {completed.returncode}")
|
|
95
|
+
# Handler may have logged to stdout before redirection in older runners;
|
|
96
|
+
# take the last JSON object line when possible.
|
|
97
|
+
stdout = (completed.stdout or "").strip()
|
|
98
|
+
try:
|
|
99
|
+
return json.loads(stdout or "{}")
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
for line in reversed(stdout.splitlines()):
|
|
102
|
+
line = line.strip()
|
|
103
|
+
if line.startswith("{") and line.endswith("}"):
|
|
104
|
+
return json.loads(line)
|
|
105
|
+
raise
|
|
106
|
+
finally:
|
|
107
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def function_name_from_path(path: str) -> str | None:
|
|
111
|
+
"""Extract a function name from a V2-compatible request path.
|
|
112
|
+
|
|
113
|
+
Accepted shapes (query string ignored):
|
|
114
|
+
- `/functions/{name}` (canonical V2 container route)
|
|
115
|
+
- `/{name}` (legacy local-dev alias)
|
|
116
|
+
"""
|
|
117
|
+
parsed = urlparse(path)
|
|
118
|
+
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
|
119
|
+
if len(parts) == 2 and parts[0] == "functions":
|
|
120
|
+
return parts[1]
|
|
121
|
+
if len(parts) == 1 and parts[0] != "functions":
|
|
122
|
+
return parts[0]
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_function_name(
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
headers: Any = None,
|
|
130
|
+
body: dict[str, Any] | None = None,
|
|
131
|
+
) -> str | None:
|
|
132
|
+
"""Resolve function name from path, X-Caraer-Function header, or body.functionName."""
|
|
133
|
+
name = function_name_from_path(path)
|
|
134
|
+
if name:
|
|
135
|
+
return name
|
|
136
|
+
if headers is not None:
|
|
137
|
+
header_name = headers.get("X-Caraer-Function") or headers.get("x-caraer-function")
|
|
138
|
+
if isinstance(header_name, str) and header_name.strip():
|
|
139
|
+
return header_name.strip()
|
|
140
|
+
if isinstance(body, dict):
|
|
141
|
+
body_name = body.get("functionName")
|
|
142
|
+
if isinstance(body_name, str) and body_name.strip():
|
|
143
|
+
return body_name.strip()
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None:
|
|
148
|
+
body = json.dumps(payload).encode("utf-8")
|
|
149
|
+
handler.send_response(status)
|
|
150
|
+
handler.send_header("Content-Type", "application/json")
|
|
151
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
152
|
+
handler.end_headers()
|
|
153
|
+
handler.wfile.write(body)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any] | list[Any] | None:
|
|
157
|
+
length = int(handler.headers.get("Content-Length") or "0")
|
|
158
|
+
raw = handler.rfile.read(length) if length else b""
|
|
159
|
+
if not raw:
|
|
160
|
+
return {}
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(raw.decode("utf-8"))
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _dev_state_path(root: Path) -> Path:
|
|
168
|
+
return root / ".caraer" / "dev-state.json"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _load_dev_store(root: Path) -> dict[str, Any]:
|
|
172
|
+
path = _dev_state_path(root)
|
|
173
|
+
if not path.is_file():
|
|
174
|
+
return {"state": {}, "secrets": {}, "jobs": {}, "connections": {}}
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
177
|
+
except (json.JSONDecodeError, OSError):
|
|
178
|
+
return {"state": {}, "secrets": {}, "jobs": {}, "connections": {}}
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
return {"state": {}, "secrets": {}, "jobs": {}, "connections": {}}
|
|
181
|
+
data.setdefault("state", {})
|
|
182
|
+
data.setdefault("secrets", {})
|
|
183
|
+
data.setdefault("jobs", {})
|
|
184
|
+
data.setdefault("connections", {})
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _save_dev_store(root: Path, store: dict[str, Any]) -> None:
|
|
189
|
+
path = _dev_state_path(root)
|
|
190
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _seed_secrets_from_env(root: Path, store: dict[str, Any]) -> None:
|
|
195
|
+
"""Seed installation secrets from .env keys that look like SECRET_* or SECRETS_*."""
|
|
196
|
+
env_path = root / ".env"
|
|
197
|
+
if not env_path.is_file():
|
|
198
|
+
return
|
|
199
|
+
secrets = store.setdefault("secrets", {})
|
|
200
|
+
if not isinstance(secrets, dict):
|
|
201
|
+
secrets = {}
|
|
202
|
+
store["secrets"] = secrets
|
|
203
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
204
|
+
text = line.strip()
|
|
205
|
+
if not text or text.startswith("#") or "=" not in text:
|
|
206
|
+
continue
|
|
207
|
+
key, _, value = text.partition("=")
|
|
208
|
+
key = key.strip()
|
|
209
|
+
value = value.strip().strip("'\"")
|
|
210
|
+
if key.startswith("SECRET_") and key not in secrets:
|
|
211
|
+
secrets[key[len("SECRET_") :]] = value
|
|
212
|
+
elif key.startswith("SECRETS_") and key not in secrets:
|
|
213
|
+
secrets[key[len("SECRETS_") :]] = value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_local_schedules(root: Path, config: ProjectConfig) -> list[dict[str, Any]]:
|
|
217
|
+
base = schedules_dir(root, config.srcDir)
|
|
218
|
+
if not base.is_dir():
|
|
219
|
+
return []
|
|
220
|
+
items: list[dict[str, Any]] = []
|
|
221
|
+
for path in sorted(base.glob("*.json")):
|
|
222
|
+
try:
|
|
223
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
224
|
+
except (json.JSONDecodeError, OSError):
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(data, dict):
|
|
227
|
+
items.append(data)
|
|
228
|
+
return items
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_local_inbound(root: Path, config: ProjectConfig) -> list[dict[str, Any]]:
|
|
232
|
+
base = inbound_dir(root, config.srcDir)
|
|
233
|
+
if not base.is_dir():
|
|
234
|
+
return []
|
|
235
|
+
items: list[dict[str, Any]] = []
|
|
236
|
+
for path in sorted(base.glob("*.json")):
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
239
|
+
except (json.JSONDecodeError, OSError):
|
|
240
|
+
continue
|
|
241
|
+
if isinstance(data, dict):
|
|
242
|
+
items.append(data)
|
|
243
|
+
return items
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _function_ref_name(ref: Any) -> str | None:
|
|
247
|
+
if isinstance(ref, dict):
|
|
248
|
+
name = ref.get("name")
|
|
249
|
+
if isinstance(name, str) and name.strip():
|
|
250
|
+
return name.strip()
|
|
251
|
+
if isinstance(ref, str) and ref.strip():
|
|
252
|
+
return ref.strip()
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _SourceWatcher:
|
|
257
|
+
"""Stat-based poll watcher for local app sources under ``src/``."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, root: Path, config: ProjectConfig) -> None:
|
|
260
|
+
self.root = root
|
|
261
|
+
self.config = config
|
|
262
|
+
self.src = root / config.srcDir
|
|
263
|
+
self._mtimes: dict[Path, float] = {}
|
|
264
|
+
self._snapshot()
|
|
265
|
+
|
|
266
|
+
def _iter_files(self) -> list[Path]:
|
|
267
|
+
if not self.src.is_dir():
|
|
268
|
+
return []
|
|
269
|
+
files: list[Path] = []
|
|
270
|
+
for path in self.src.rglob("*"):
|
|
271
|
+
if not path.is_file():
|
|
272
|
+
continue
|
|
273
|
+
if path.name.startswith(".") or "__pycache__" in path.parts:
|
|
274
|
+
continue
|
|
275
|
+
if path.suffix.lower() in {
|
|
276
|
+
".js",
|
|
277
|
+
".mjs",
|
|
278
|
+
".cjs",
|
|
279
|
+
".ts",
|
|
280
|
+
".py",
|
|
281
|
+
".json",
|
|
282
|
+
".yaml",
|
|
283
|
+
".yml",
|
|
284
|
+
}:
|
|
285
|
+
files.append(path)
|
|
286
|
+
return files
|
|
287
|
+
|
|
288
|
+
def _snapshot(self) -> None:
|
|
289
|
+
current: dict[Path, float] = {}
|
|
290
|
+
for path in self._iter_files():
|
|
291
|
+
try:
|
|
292
|
+
current[path] = path.stat().st_mtime
|
|
293
|
+
except OSError:
|
|
294
|
+
continue
|
|
295
|
+
self._mtimes = current
|
|
296
|
+
|
|
297
|
+
def poll(self) -> list[Path]:
|
|
298
|
+
previous = self._mtimes
|
|
299
|
+
current: dict[Path, float] = {}
|
|
300
|
+
changed: list[Path] = []
|
|
301
|
+
for path in self._iter_files():
|
|
302
|
+
try:
|
|
303
|
+
mtime = path.stat().st_mtime
|
|
304
|
+
except OSError:
|
|
305
|
+
continue
|
|
306
|
+
current[path] = mtime
|
|
307
|
+
if previous.get(path) != mtime:
|
|
308
|
+
changed.append(path)
|
|
309
|
+
for path in previous:
|
|
310
|
+
if path not in current:
|
|
311
|
+
changed.append(path)
|
|
312
|
+
self._mtimes = current
|
|
313
|
+
return changed
|
|
314
|
+
|
|
315
|
+
def function_name_for(self, path: Path) -> str | None:
|
|
316
|
+
try:
|
|
317
|
+
rel = path.relative_to(functions_dir(self.root, self.config.srcDir))
|
|
318
|
+
except ValueError:
|
|
319
|
+
return None
|
|
320
|
+
if not rel.parts:
|
|
321
|
+
return None
|
|
322
|
+
return rel.parts[0]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def serve_functions(
|
|
326
|
+
root: Path,
|
|
327
|
+
config: ProjectConfig,
|
|
328
|
+
*,
|
|
329
|
+
host: str,
|
|
330
|
+
port: int,
|
|
331
|
+
function_names: list[str] | None = None,
|
|
332
|
+
invoke_schedule: str | None = None,
|
|
333
|
+
enable_installation_shim: bool = True,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Serve local functions with the V2 container invoke contract.
|
|
336
|
+
|
|
337
|
+
Canonical invoke: POST /functions/{name}
|
|
338
|
+
Also accepts: POST /{name}, X-Caraer-Function header, or body.functionName.
|
|
339
|
+
When enable_installation_shim is True, also serves local installation APIs.
|
|
340
|
+
Reloads function discovery when files under src/ change (hot reload).
|
|
341
|
+
"""
|
|
342
|
+
load_dotenv(root / ".env")
|
|
343
|
+
available = list_local_function_names(root, config)
|
|
344
|
+
if function_names:
|
|
345
|
+
unknown = [name for name in function_names if name not in available]
|
|
346
|
+
if unknown:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Unknown function(s): {', '.join(unknown)}. "
|
|
349
|
+
f"Available: {', '.join(available) or '(none)'}"
|
|
350
|
+
)
|
|
351
|
+
names = list(function_names)
|
|
352
|
+
lock_names = True
|
|
353
|
+
else:
|
|
354
|
+
names = available
|
|
355
|
+
lock_names = False
|
|
356
|
+
if not names:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
"No local functions found under src/app/functions/. "
|
|
359
|
+
"Add a function folder first."
|
|
360
|
+
)
|
|
361
|
+
name_set = set(names)
|
|
362
|
+
|
|
363
|
+
app_uuid = str(config.appUuid or "local-dev-app")
|
|
364
|
+
store = _load_dev_store(root)
|
|
365
|
+
_seed_secrets_from_env(root, store)
|
|
366
|
+
_save_dev_store(root, store)
|
|
367
|
+
|
|
368
|
+
# Env injection so example apps that call Caraer installation APIs work locally.
|
|
369
|
+
base_url = f"http://{host}:{port}"
|
|
370
|
+
os.environ.setdefault("CARAER_DEV_BASE_URL", base_url)
|
|
371
|
+
os.environ.setdefault("CARAER_API_BASE_URL", base_url)
|
|
372
|
+
os.environ.setdefault("CARAER_INSTALLATION_TOKEN", "local-dev-installation-token")
|
|
373
|
+
os.environ.setdefault("CARAER_APP_UUID", app_uuid)
|
|
374
|
+
|
|
375
|
+
schedules = _load_local_schedules(root, config)
|
|
376
|
+
inbound_routes = _load_local_inbound(root, config)
|
|
377
|
+
job_lock = threading.Lock()
|
|
378
|
+
watcher = _SourceWatcher(root, config)
|
|
379
|
+
reload_lock = threading.Lock()
|
|
380
|
+
|
|
381
|
+
def _refresh_from_disk(*, announce: bool = True) -> list[str]:
|
|
382
|
+
nonlocal names, name_set, schedules, inbound_routes
|
|
383
|
+
changed = watcher.poll()
|
|
384
|
+
if not changed:
|
|
385
|
+
return []
|
|
386
|
+
reloaded_functions: list[str] = []
|
|
387
|
+
with reload_lock:
|
|
388
|
+
if not lock_names:
|
|
389
|
+
names = list_local_function_names(root, config)
|
|
390
|
+
name_set = set(names)
|
|
391
|
+
schedules = _load_local_schedules(root, config)
|
|
392
|
+
inbound_routes = _load_local_inbound(root, config)
|
|
393
|
+
for path in changed:
|
|
394
|
+
fn_name = watcher.function_name_for(path)
|
|
395
|
+
if fn_name and fn_name in name_set and fn_name not in reloaded_functions:
|
|
396
|
+
reloaded_functions.append(fn_name)
|
|
397
|
+
if announce:
|
|
398
|
+
for fn_name in reloaded_functions:
|
|
399
|
+
print(f"reloaded {fn_name}", flush=True)
|
|
400
|
+
if changed and not reloaded_functions:
|
|
401
|
+
print(f"reloaded config ({len(changed)} file(s))", flush=True)
|
|
402
|
+
return reloaded_functions
|
|
403
|
+
|
|
404
|
+
def _run_function(fn_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
405
|
+
_refresh_from_disk(announce=True)
|
|
406
|
+
runtime, entry = _load_local_function(root, config, fn_name)
|
|
407
|
+
return invoke_local(runtime, entry, payload)
|
|
408
|
+
|
|
409
|
+
def _enqueue_job(
|
|
410
|
+
fn_name: str,
|
|
411
|
+
payload: dict[str, Any],
|
|
412
|
+
*,
|
|
413
|
+
delay_seconds: float = 0,
|
|
414
|
+
) -> dict[str, Any]:
|
|
415
|
+
job_id = str(uuid.uuid4())
|
|
416
|
+
job = {
|
|
417
|
+
"jobId": job_id,
|
|
418
|
+
"status": "QUEUED",
|
|
419
|
+
"functionName": fn_name,
|
|
420
|
+
"payload": payload,
|
|
421
|
+
"createdAt": time.time(),
|
|
422
|
+
}
|
|
423
|
+
with job_lock:
|
|
424
|
+
store_local = _load_dev_store(root)
|
|
425
|
+
jobs = store_local.setdefault("jobs", {})
|
|
426
|
+
jobs[job_id] = job
|
|
427
|
+
_save_dev_store(root, store_local)
|
|
428
|
+
|
|
429
|
+
def _worker() -> None:
|
|
430
|
+
if delay_seconds > 0:
|
|
431
|
+
time.sleep(delay_seconds)
|
|
432
|
+
with job_lock:
|
|
433
|
+
current = _load_dev_store(root)
|
|
434
|
+
jobs_map = current.setdefault("jobs", {})
|
|
435
|
+
entry = jobs_map.get(job_id) or job
|
|
436
|
+
entry["status"] = "RUNNING"
|
|
437
|
+
jobs_map[job_id] = entry
|
|
438
|
+
_save_dev_store(root, current)
|
|
439
|
+
try:
|
|
440
|
+
result = _run_function(fn_name, payload)
|
|
441
|
+
status = "SUCCEEDED"
|
|
442
|
+
error = None
|
|
443
|
+
except Exception as exc: # noqa: BLE001
|
|
444
|
+
result = None
|
|
445
|
+
status = "FAILED"
|
|
446
|
+
error = str(exc)
|
|
447
|
+
with job_lock:
|
|
448
|
+
current = _load_dev_store(root)
|
|
449
|
+
jobs_map = current.setdefault("jobs", {})
|
|
450
|
+
entry = jobs_map.get(job_id) or job
|
|
451
|
+
entry["status"] = status
|
|
452
|
+
entry["result"] = result
|
|
453
|
+
if error:
|
|
454
|
+
entry["error"] = error
|
|
455
|
+
jobs_map[job_id] = entry
|
|
456
|
+
_save_dev_store(root, current)
|
|
457
|
+
|
|
458
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
459
|
+
return {"jobId": job_id, "status": "QUEUED"}
|
|
460
|
+
|
|
461
|
+
def _handle_installation(
|
|
462
|
+
handler: BaseHTTPRequestHandler,
|
|
463
|
+
method: str,
|
|
464
|
+
parts: list[str],
|
|
465
|
+
body: dict[str, Any] | list[Any] | None,
|
|
466
|
+
) -> bool:
|
|
467
|
+
"""Handle /api/v2/apps/{uuid}/installation/... Returns True if handled."""
|
|
468
|
+
# parts: ["api", "v2", "apps", "{uuid}", "installation", ...]
|
|
469
|
+
if len(parts) < 5 or parts[0:3] != ["api", "v2", "apps"] or parts[4] != "installation":
|
|
470
|
+
return False
|
|
471
|
+
rest = parts[5:]
|
|
472
|
+
current = _load_dev_store(root)
|
|
473
|
+
|
|
474
|
+
if not rest:
|
|
475
|
+
_json_response(
|
|
476
|
+
handler,
|
|
477
|
+
200,
|
|
478
|
+
{
|
|
479
|
+
"state": current.get("state") or {},
|
|
480
|
+
"secrets": sorted((current.get("secrets") or {}).keys()),
|
|
481
|
+
"connections": current.get("connections") or {},
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
resource = rest[0]
|
|
487
|
+
|
|
488
|
+
if resource == "state":
|
|
489
|
+
state = current.setdefault("state", {})
|
|
490
|
+
if not isinstance(state, dict):
|
|
491
|
+
state = {}
|
|
492
|
+
current["state"] = state
|
|
493
|
+
if len(rest) == 1:
|
|
494
|
+
if method == "GET":
|
|
495
|
+
_json_response(handler, 200, {"message": "Success", "data": state})
|
|
496
|
+
return True
|
|
497
|
+
if method == "PUT" and isinstance(body, dict):
|
|
498
|
+
state.update(body)
|
|
499
|
+
_save_dev_store(root, current)
|
|
500
|
+
_json_response(handler, 200, {"message": "Success", "data": state})
|
|
501
|
+
return True
|
|
502
|
+
if len(rest) == 2:
|
|
503
|
+
key = rest[1]
|
|
504
|
+
if method == "GET":
|
|
505
|
+
_json_response(
|
|
506
|
+
handler,
|
|
507
|
+
200,
|
|
508
|
+
{"message": "Success", "data": {key: state.get(key)}},
|
|
509
|
+
)
|
|
510
|
+
return True
|
|
511
|
+
if method == "PUT" and isinstance(body, dict):
|
|
512
|
+
value = body.get("value", body)
|
|
513
|
+
state[key] = value
|
|
514
|
+
_save_dev_store(root, current)
|
|
515
|
+
_json_response(
|
|
516
|
+
handler,
|
|
517
|
+
200,
|
|
518
|
+
{"message": "Success", "data": {key: state.get(key)}},
|
|
519
|
+
)
|
|
520
|
+
return True
|
|
521
|
+
if method == "DELETE":
|
|
522
|
+
state.pop(key, None)
|
|
523
|
+
_save_dev_store(root, current)
|
|
524
|
+
_json_response(handler, 200, {"message": "Success", "data": True})
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
if resource == "secrets":
|
|
528
|
+
secrets = current.setdefault("secrets", {})
|
|
529
|
+
if not isinstance(secrets, dict):
|
|
530
|
+
secrets = {}
|
|
531
|
+
current["secrets"] = secrets
|
|
532
|
+
if len(rest) == 1 and method == "GET":
|
|
533
|
+
_json_response(
|
|
534
|
+
handler,
|
|
535
|
+
200,
|
|
536
|
+
{"message": "Success", "data": sorted(secrets.keys())},
|
|
537
|
+
)
|
|
538
|
+
return True
|
|
539
|
+
if len(rest) == 2:
|
|
540
|
+
name = rest[1]
|
|
541
|
+
if method == "PUT" and isinstance(body, dict):
|
|
542
|
+
secrets[name] = body.get("value", "")
|
|
543
|
+
_save_dev_store(root, current)
|
|
544
|
+
_json_response(handler, 200, {"message": "Success", "data": {"name": name}})
|
|
545
|
+
return True
|
|
546
|
+
if method == "DELETE":
|
|
547
|
+
secrets.pop(name, None)
|
|
548
|
+
_save_dev_store(root, current)
|
|
549
|
+
_json_response(handler, 200, {"message": "Success", "data": True})
|
|
550
|
+
return True
|
|
551
|
+
|
|
552
|
+
if resource == "connections":
|
|
553
|
+
connections = current.setdefault("connections", {})
|
|
554
|
+
if not isinstance(connections, dict):
|
|
555
|
+
connections = {}
|
|
556
|
+
current["connections"] = connections
|
|
557
|
+
if len(rest) == 1 and method == "GET":
|
|
558
|
+
_json_response(handler, 200, {"message": "Success", "data": connections})
|
|
559
|
+
return True
|
|
560
|
+
if len(rest) == 2 and method == "DELETE":
|
|
561
|
+
connections.pop(rest[1], None)
|
|
562
|
+
_save_dev_store(root, current)
|
|
563
|
+
_json_response(handler, 200, {"message": "Success", "data": True})
|
|
564
|
+
return True
|
|
565
|
+
|
|
566
|
+
if resource == "jobs":
|
|
567
|
+
jobs = current.setdefault("jobs", {})
|
|
568
|
+
if not isinstance(jobs, dict):
|
|
569
|
+
jobs = {}
|
|
570
|
+
current["jobs"] = jobs
|
|
571
|
+
if len(rest) == 1 and method == "POST" and isinstance(body, dict):
|
|
572
|
+
fn_name = str(body.get("functionName") or "").strip()
|
|
573
|
+
if fn_name not in name_set:
|
|
574
|
+
_json_response(handler, 404, {"error": f"function not found: {fn_name}"})
|
|
575
|
+
return True
|
|
576
|
+
payload = body.get("payload") if isinstance(body.get("payload"), dict) else {}
|
|
577
|
+
delay = float(body.get("delaySeconds") or 0)
|
|
578
|
+
result = _enqueue_job(fn_name, payload, delay_seconds=delay)
|
|
579
|
+
_json_response(handler, 200, {"message": "Success", "data": result})
|
|
580
|
+
return True
|
|
581
|
+
if len(rest) == 2 and method == "GET":
|
|
582
|
+
job = jobs.get(rest[1])
|
|
583
|
+
if not job:
|
|
584
|
+
_json_response(handler, 404, {"error": "job not found"})
|
|
585
|
+
return True
|
|
586
|
+
_json_response(handler, 200, {"message": "Success", "data": job})
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
_json_response(handler, 404, {"error": "installation route not found"})
|
|
590
|
+
return True
|
|
591
|
+
|
|
592
|
+
def _handle_inbound(
|
|
593
|
+
handler: BaseHTTPRequestHandler,
|
|
594
|
+
parts: list[str],
|
|
595
|
+
body: dict[str, Any] | list[Any] | None,
|
|
596
|
+
) -> bool:
|
|
597
|
+
"""Handle POST /inbound/{routeName} or public inbound path."""
|
|
598
|
+
route_name: str | None = None
|
|
599
|
+
if len(parts) == 2 and parts[0] == "inbound":
|
|
600
|
+
route_name = parts[1]
|
|
601
|
+
elif (
|
|
602
|
+
len(parts) >= 6
|
|
603
|
+
and parts[0:3] == ["api", "v2", "public", "apps"]
|
|
604
|
+
and parts[4] == "inbound"
|
|
605
|
+
):
|
|
606
|
+
route_name = parts[5]
|
|
607
|
+
if not route_name:
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
route = next(
|
|
611
|
+
(
|
|
612
|
+
item
|
|
613
|
+
for item in inbound_routes
|
|
614
|
+
if str(item.get("name") or "") == route_name
|
|
615
|
+
),
|
|
616
|
+
None,
|
|
617
|
+
)
|
|
618
|
+
if route is None:
|
|
619
|
+
_json_response(handler, 404, {"error": f"inbound route not found: {route_name}"})
|
|
620
|
+
return True
|
|
621
|
+
|
|
622
|
+
auth_mode = str(route.get("authMode") or "NONE").upper()
|
|
623
|
+
if auth_mode == "SHARED_SECRET":
|
|
624
|
+
expected = str(route.get("sharedSecret") or "")
|
|
625
|
+
provided = (
|
|
626
|
+
handler.headers.get("X-Caraer-Inbound-Secret")
|
|
627
|
+
or handler.headers.get("Authorization")
|
|
628
|
+
or ""
|
|
629
|
+
)
|
|
630
|
+
if provided.startswith("Bearer "):
|
|
631
|
+
provided = provided[len("Bearer ") :]
|
|
632
|
+
if expected and provided != expected:
|
|
633
|
+
_json_response(handler, 401, {"error": "invalid inbound secret"})
|
|
634
|
+
return True
|
|
635
|
+
|
|
636
|
+
fn_name = _function_ref_name(route.get("serverlessFunction"))
|
|
637
|
+
if not fn_name or fn_name not in name_set:
|
|
638
|
+
_json_response(
|
|
639
|
+
handler,
|
|
640
|
+
400,
|
|
641
|
+
{"error": f"inbound route '{route_name}' has no local function"},
|
|
642
|
+
)
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
payload = body if isinstance(body, dict) else {"body": body}
|
|
646
|
+
if route.get("enqueue"):
|
|
647
|
+
result = _enqueue_job(fn_name, payload)
|
|
648
|
+
_json_response(handler, 200, {"message": "Success", "data": result})
|
|
649
|
+
return True
|
|
650
|
+
try:
|
|
651
|
+
result = _run_function(fn_name, payload)
|
|
652
|
+
_json_response(handler, 200, result)
|
|
653
|
+
except Exception as exc: # noqa: BLE001
|
|
654
|
+
_json_response(handler, 500, {"error": str(exc)})
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
class Handler(BaseHTTPRequestHandler):
|
|
658
|
+
def do_GET(self): # noqa: N802
|
|
659
|
+
parsed = urlparse(self.path)
|
|
660
|
+
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
|
661
|
+
if enable_installation_shim and _handle_installation(self, "GET", parts, None):
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
if not parts:
|
|
665
|
+
_refresh_from_disk(announce=True)
|
|
666
|
+
_json_response(
|
|
667
|
+
self,
|
|
668
|
+
200,
|
|
669
|
+
{
|
|
670
|
+
"functions": names,
|
|
671
|
+
"invoke": [
|
|
672
|
+
"POST /functions/<function_name>",
|
|
673
|
+
"POST /<function_name>",
|
|
674
|
+
"POST / with X-Caraer-Function header or body.functionName",
|
|
675
|
+
],
|
|
676
|
+
"installation": (
|
|
677
|
+
f"/api/v2/apps/{app_uuid}/installation/..."
|
|
678
|
+
if enable_installation_shim
|
|
679
|
+
else None
|
|
680
|
+
),
|
|
681
|
+
"inbound": "POST /inbound/<routeName>",
|
|
682
|
+
"hotReload": True,
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
name = resolve_function_name(self.path, headers=self.headers)
|
|
688
|
+
if name is None or name not in name_set:
|
|
689
|
+
_json_response(
|
|
690
|
+
self,
|
|
691
|
+
404,
|
|
692
|
+
{
|
|
693
|
+
"error": "function not found",
|
|
694
|
+
"functions": names,
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
return
|
|
698
|
+
_json_response(
|
|
699
|
+
self,
|
|
700
|
+
200,
|
|
701
|
+
{
|
|
702
|
+
"function": name,
|
|
703
|
+
"invoke": [
|
|
704
|
+
f"POST /functions/{name}",
|
|
705
|
+
f"POST /{name}",
|
|
706
|
+
],
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def do_PUT(self): # noqa: N802
|
|
711
|
+
parsed = urlparse(self.path)
|
|
712
|
+
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
|
713
|
+
body = _read_json_body(self)
|
|
714
|
+
if body is None:
|
|
715
|
+
_json_response(self, 400, {"error": "invalid json"})
|
|
716
|
+
return
|
|
717
|
+
if enable_installation_shim and _handle_installation(self, "PUT", parts, body):
|
|
718
|
+
return
|
|
719
|
+
_json_response(self, 404, {"error": "not found"})
|
|
720
|
+
|
|
721
|
+
def do_DELETE(self): # noqa: N802
|
|
722
|
+
parsed = urlparse(self.path)
|
|
723
|
+
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
|
724
|
+
if enable_installation_shim and _handle_installation(self, "DELETE", parts, None):
|
|
725
|
+
return
|
|
726
|
+
_json_response(self, 404, {"error": "not found"})
|
|
727
|
+
|
|
728
|
+
def do_POST(self): # noqa: N802
|
|
729
|
+
parsed = urlparse(self.path)
|
|
730
|
+
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
|
731
|
+
body = _read_json_body(self)
|
|
732
|
+
if body is None:
|
|
733
|
+
_json_response(self, 400, {"error": "invalid json"})
|
|
734
|
+
return
|
|
735
|
+
if not isinstance(body, dict):
|
|
736
|
+
# inbound may accept non-objects; installation/functions need objects
|
|
737
|
+
if not _handle_inbound(self, parts, body):
|
|
738
|
+
_json_response(self, 400, {"error": "json body must be an object"})
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
if enable_installation_shim and _handle_installation(self, "POST", parts, body):
|
|
742
|
+
return
|
|
743
|
+
if _handle_inbound(self, parts, body):
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
name = resolve_function_name(self.path, headers=self.headers, body=body)
|
|
747
|
+
if name is None or name not in name_set:
|
|
748
|
+
_json_response(
|
|
749
|
+
self,
|
|
750
|
+
404,
|
|
751
|
+
{
|
|
752
|
+
"error": "function not found",
|
|
753
|
+
"functions": names,
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
return
|
|
757
|
+
try:
|
|
758
|
+
result = _run_function(name, body)
|
|
759
|
+
_json_response(self, 200, result)
|
|
760
|
+
except Exception as exc: # noqa: BLE001
|
|
761
|
+
_json_response(self, 500, {"error": str(exc)})
|
|
762
|
+
|
|
763
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
if invoke_schedule:
|
|
767
|
+
schedule = next(
|
|
768
|
+
(item for item in schedules if str(item.get("name") or "") == invoke_schedule),
|
|
769
|
+
None,
|
|
770
|
+
)
|
|
771
|
+
if schedule is None:
|
|
772
|
+
available_names = [str(s.get("name") or "") for s in schedules]
|
|
773
|
+
raise ValueError(
|
|
774
|
+
f"Unknown schedule '{invoke_schedule}'. "
|
|
775
|
+
f"Available: {', '.join(n for n in available_names if n) or '(none)'}"
|
|
776
|
+
)
|
|
777
|
+
fn_name = _function_ref_name(schedule.get("serverlessFunction"))
|
|
778
|
+
if not fn_name or fn_name not in name_set:
|
|
779
|
+
raise ValueError(
|
|
780
|
+
f"Schedule '{invoke_schedule}' references unknown function '{fn_name}'."
|
|
781
|
+
)
|
|
782
|
+
payload = schedule.get("payloadTemplate")
|
|
783
|
+
if not isinstance(payload, dict):
|
|
784
|
+
payload = {}
|
|
785
|
+
result = _run_function(fn_name, payload)
|
|
786
|
+
print(json.dumps({"schedule": invoke_schedule, "function": fn_name, "result": result}))
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
def _watch_loop() -> None:
|
|
790
|
+
while True:
|
|
791
|
+
time.sleep(1.0)
|
|
792
|
+
try:
|
|
793
|
+
_refresh_from_disk(announce=True)
|
|
794
|
+
except Exception: # noqa: BLE001
|
|
795
|
+
continue
|
|
796
|
+
|
|
797
|
+
threading.Thread(target=_watch_loop, daemon=True).start()
|
|
798
|
+
print(
|
|
799
|
+
f"Local dev server on {base_url} (hot reload enabled). "
|
|
800
|
+
f"Functions: {', '.join(names)}",
|
|
801
|
+
flush=True,
|
|
802
|
+
)
|
|
803
|
+
server = ThreadingHTTPServer((host, port), Handler)
|
|
804
|
+
try:
|
|
805
|
+
server.serve_forever()
|
|
806
|
+
except KeyboardInterrupt:
|
|
807
|
+
server.shutdown()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def serve_function(
|
|
811
|
+
root: Path,
|
|
812
|
+
config: ProjectConfig,
|
|
813
|
+
*,
|
|
814
|
+
function_name: str,
|
|
815
|
+
host: str,
|
|
816
|
+
port: int,
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Backward-compatible wrapper: serve one function at /functions/<name>."""
|
|
819
|
+
serve_functions(
|
|
820
|
+
root,
|
|
821
|
+
config,
|
|
822
|
+
host=host,
|
|
823
|
+
port=port,
|
|
824
|
+
function_names=[function_name],
|
|
825
|
+
)
|