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.
Files changed (83) hide show
  1. caraer_cli/__init__.py +4 -0
  2. caraer_cli/api/__init__.py +1 -0
  3. caraer_cli/api/apps.py +78 -0
  4. caraer_cli/api/auth.py +53 -0
  5. caraer_cli/api/client.py +179 -0
  6. caraer_cli/api/functions.py +72 -0
  7. caraer_cli/api/installation.py +93 -0
  8. caraer_cli/api/integration_runtime.py +77 -0
  9. caraer_cli/api/projects.py +213 -0
  10. caraer_cli/api/sandboxes.py +29 -0
  11. caraer_cli/api/webhooks.py +59 -0
  12. caraer_cli/app_sync.py +697 -0
  13. caraer_cli/apps_local.py +274 -0
  14. caraer_cli/commands/__init__.py +1 -0
  15. caraer_cli/commands/apps.py +2025 -0
  16. caraer_cli/commands/auth.py +173 -0
  17. caraer_cli/commands/company.py +68 -0
  18. caraer_cli/commands/installation.py +182 -0
  19. caraer_cli/commands/profile.py +239 -0
  20. caraer_cli/commands/publish.py +40 -0
  21. caraer_cli/commands/sandbox.py +92 -0
  22. caraer_cli/commands/skill.py +165 -0
  23. caraer_cli/commands/webhooks.py +68 -0
  24. caraer_cli/completion.py +77 -0
  25. caraer_cli/completion_callbacks.py +162 -0
  26. caraer_cli/context.py +41 -0
  27. caraer_cli/errors.py +84 -0
  28. caraer_cli/formatters/__init__.py +1 -0
  29. caraer_cli/formatters/output.py +381 -0
  30. caraer_cli/formatters/timestamps.py +98 -0
  31. caraer_cli/install_completion.py +38 -0
  32. caraer_cli/local_app.py +135 -0
  33. caraer_cli/main.py +104 -0
  34. caraer_cli/models/__init__.py +1 -0
  35. caraer_cli/models/api.py +52 -0
  36. caraer_cli/project/__init__.py +17 -0
  37. caraer_cli/project/app_bars_sync.py +154 -0
  38. caraer_cli/project/app_manifest_template.py +68 -0
  39. caraer_cli/project/inbound_sync.py +192 -0
  40. caraer_cli/project/json_schemas.py +66 -0
  41. caraer_cli/project/lifecycle_sync.py +119 -0
  42. caraer_cli/project/local_dev.py +825 -0
  43. caraer_cli/project/manifest_edit.py +51 -0
  44. caraer_cli/project/marketplace_assemble.py +165 -0
  45. caraer_cli/project/oauth_providers_sync.py +180 -0
  46. caraer_cli/project/paths.py +102 -0
  47. caraer_cli/project/pricing_sync.py +81 -0
  48. caraer_cli/project/push_plan.py +590 -0
  49. caraer_cli/project/release.py +128 -0
  50. caraer_cli/project/scaffold.py +537 -0
  51. caraer_cli/project/schedules_sync.py +170 -0
  52. caraer_cli/project/schema.py +160 -0
  53. caraer_cli/project/settings_sync.py +77 -0
  54. caraer_cli/project/state.py +51 -0
  55. caraer_cli/project/sync.py +308 -0
  56. caraer_cli/project/typegen.py +278 -0
  57. caraer_cli/project/validate_app.py +1215 -0
  58. caraer_cli/project/webhooks_sync.py +233 -0
  59. caraer_cli/resolve.py +55 -0
  60. caraer_cli/schemas/app.caraer.schema.json +175 -0
  61. caraer_cli/schemas/function.caraer.schema.json +29 -0
  62. caraer_cli/schemas/inbound.caraer.schema.json +49 -0
  63. caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
  64. caraer_cli/schemas/schedule.caraer.schema.json +39 -0
  65. caraer_cli/schemas/webhook.caraer.schema.json +50 -0
  66. caraer_cli/skills/.gitkeep +2 -0
  67. caraer_cli/skills/caraer-apps/SKILL.md +172 -0
  68. caraer_cli/skills/caraer-apps/reference.md +163 -0
  69. caraer_cli/state/__init__.py +1 -0
  70. caraer_cli/state/config.py +148 -0
  71. caraer_cli/state/session.py +98 -0
  72. caraer_cli/utils.py +40 -0
  73. caraer_cli/wizard/__init__.py +9 -0
  74. caraer_cli/wizard/catalog.py +170 -0
  75. caraer_cli/wizard/marketplace.py +350 -0
  76. caraer_cli/wizard/prompts.py +99 -0
  77. caraer_cli/wizard/public_app.py +519 -0
  78. caraer_cli/wizard/scope_macros.py +214 -0
  79. caraer_cli-0.1.2.dist-info/METADATA +266 -0
  80. caraer_cli-0.1.2.dist-info/RECORD +83 -0
  81. caraer_cli-0.1.2.dist-info/WHEEL +4 -0
  82. caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
  83. 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
+ )