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,128 @@
1
+ """Release version + notes helpers for developer project builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from caraer_cli.api import projects as projects_api
9
+ from caraer_cli.api.client import CaraerApiClient
10
+
11
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
12
+
13
+
14
+ def parse_semver(version: str) -> tuple[int, int, int]:
15
+ match = SEMVER_RE.fullmatch(version.strip())
16
+ if not match:
17
+ raise ValueError("version must be MAJOR.MINOR.PATCH (e.g. 1.2.3)")
18
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
19
+
20
+
21
+ def is_semver(version: str | None) -> bool:
22
+ return bool(version and SEMVER_RE.fullmatch(version.strip()))
23
+
24
+
25
+ def bump_patch(version: str) -> str:
26
+ major, minor, patch = parse_semver(version)
27
+ return f"{major}.{minor}.{patch + 1}"
28
+
29
+
30
+ def is_greater(candidate: str, previous: str) -> bool:
31
+ return parse_semver(candidate) > parse_semver(previous)
32
+
33
+
34
+ def next_version(previous: str | None) -> str:
35
+ if previous and is_semver(previous):
36
+ return bump_patch(previous)
37
+ return "0.1.0"
38
+
39
+
40
+ def latest_build_version(client: CaraerApiClient, project_uuid: str) -> str | None:
41
+ """Return the highest semver among project builds, if any."""
42
+ response = projects_api.list_builds(client, project_uuid)
43
+ rows = response.get("data") or []
44
+ best: tuple[int, int, int] | None = None
45
+ best_text: str | None = None
46
+ if not isinstance(rows, list):
47
+ return None
48
+ for item in rows:
49
+ if not isinstance(item, dict):
50
+ continue
51
+ value = str(item.get("version") or "").strip()
52
+ if not is_semver(value):
53
+ continue
54
+ parsed = parse_semver(value)
55
+ if best is None or parsed > best:
56
+ best = parsed
57
+ best_text = value
58
+ return best_text
59
+
60
+
61
+ def prompt_release(
62
+ *,
63
+ previous: str | None,
64
+ default_version: str | None = None,
65
+ default_notes: str | None = None,
66
+ ) -> tuple[str, str]:
67
+ """Interactively ask for the next release version and notes."""
68
+ from caraer_cli.wizard.prompts import ask_text
69
+
70
+ suggested = default_version.strip() if default_version and is_semver(default_version) else None
71
+ if suggested and previous and is_semver(previous) and not is_greater(suggested, previous):
72
+ suggested = next_version(previous)
73
+ if not suggested:
74
+ suggested = next_version(previous)
75
+
76
+ previous_label = previous if previous else "none"
77
+ while True:
78
+ version = ask_text(
79
+ f"Release version (previous: {previous_label})",
80
+ default=suggested,
81
+ required=True,
82
+ ).strip()
83
+ if not is_semver(version):
84
+ print("Version must be MAJOR.MINOR.PATCH (e.g. 1.2.3).")
85
+ continue
86
+ if previous and is_semver(previous) and not is_greater(version, previous):
87
+ print(f"Version must be greater than previous ({previous}).")
88
+ continue
89
+ break
90
+
91
+ notes = ask_text(
92
+ "Release notes",
93
+ default=(default_notes or "").strip(),
94
+ required=True,
95
+ ).strip()
96
+ return version, notes
97
+
98
+
99
+ def resolve_release_for_build(
100
+ client: CaraerApiClient,
101
+ project_uuid: str,
102
+ *,
103
+ version: str | None = None,
104
+ release_notes: str | None = None,
105
+ interactive: bool = True,
106
+ ) -> tuple[str, str]:
107
+ """Resolve version + notes, prompting when interactive."""
108
+ previous = latest_build_version(client, project_uuid)
109
+
110
+ if not interactive:
111
+ if not version or not is_semver(version):
112
+ raise ValueError(
113
+ "Non-interactive push requires --version MAJOR.MINOR.PATCH."
114
+ )
115
+ if previous and not is_greater(version, previous):
116
+ raise ValueError(
117
+ f"Version must be greater than previous ({previous})."
118
+ )
119
+ notes = (release_notes or "").strip()
120
+ if not notes:
121
+ raise ValueError("Non-interactive push requires --notes.")
122
+ return version.strip(), notes
123
+
124
+ return prompt_release(
125
+ previous=previous,
126
+ default_version=version,
127
+ default_notes=release_notes,
128
+ )
@@ -0,0 +1,537 @@
1
+ """Scaffold a local Caraer app directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from caraer_cli.project.app_manifest_template import render_app_manifest
10
+ from caraer_cli.project.lifecycle_sync import LIFECYCLE_HOOKS
11
+ from caraer_cli.project.paths import (
12
+ WORKSPACE_FILE,
13
+ app_bars_dir,
14
+ functions_dir,
15
+ lifecycle_dir,
16
+ pricing_dir,
17
+ settings_dir,
18
+ webhooks_dir,
19
+ workspace_file,
20
+ )
21
+ from caraer_cli.project.schema import PLATFORM_VERSION, ProjectConfig, save_project_config
22
+ from caraer_cli.project.state import set_project_uuid
23
+ from caraer_cli.project.sync import scaffold_function
24
+
25
+
26
+ def scaffold_webhook(
27
+ root: Path,
28
+ config: ProjectConfig,
29
+ *,
30
+ topic: str = "record.created",
31
+ function_name: str | None = None,
32
+ delivery_mode: str = "SERVERLESS",
33
+ url: str | None = None,
34
+ webhook_format: str = "USER_FRIENDLY",
35
+ description: str | None = None,
36
+ filename: str | None = None,
37
+ force: bool = False,
38
+ ) -> Path:
39
+ """Write a webhook JSON under ``src/app/webhooks/``."""
40
+ mode = (delivery_mode or "SERVERLESS").strip().upper()
41
+ if mode not in {"SERVERLESS", "HTTP"}:
42
+ raise ValueError("delivery_mode must be SERVERLESS or HTTP")
43
+ if mode == "SERVERLESS" and (not function_name or not function_name.strip()):
44
+ raise ValueError("function_name is required for SERVERLESS webhooks")
45
+ if mode == "HTTP" and (not url or not url.strip()):
46
+ raise ValueError("url is required for HTTP webhooks")
47
+
48
+ base = webhooks_dir(root, config.srcDir)
49
+ base.mkdir(parents=True, exist_ok=True)
50
+ if filename:
51
+ stem = filename.strip()
52
+ if stem.endswith(".json"):
53
+ stem = stem[: -len(".json")]
54
+ path = base / f"{stem}.json"
55
+ else:
56
+ slug = topic.strip().lower().replace(".", "-").replace("_", "-")
57
+ suffix = "serverless" if mode == "SERVERLESS" else "http"
58
+ path = base / f"{slug}-{suffix}.json"
59
+
60
+ if path.exists() and not force:
61
+ raise FileExistsError(f"Webhook file already exists: {path}. Use --force to overwrite.")
62
+
63
+ if mode == "SERVERLESS":
64
+ resolved_description = description or f"Invoke {function_name} on {topic}"
65
+ payload: dict[str, Any] = {
66
+ "topic": topic,
67
+ "deliveryMode": "SERVERLESS",
68
+ "webhookFormat": webhook_format,
69
+ "description": resolved_description,
70
+ "enabled": True,
71
+ "serverlessFunction": {"name": function_name},
72
+ }
73
+ else:
74
+ resolved_description = description or f"HTTP webhook for {topic}"
75
+ payload = {
76
+ "topic": topic,
77
+ "deliveryMode": "HTTP",
78
+ "webhookFormat": webhook_format,
79
+ "description": resolved_description,
80
+ "enabled": True,
81
+ "url": url.strip(),
82
+ }
83
+ from caraer_cli.project.json_schemas import WEBHOOK_SCHEMA_URL, dump_json_with_schema
84
+
85
+ dump_json_with_schema(path, payload, WEBHOOK_SCHEMA_URL)
86
+ return path
87
+
88
+
89
+ def scaffold_setting(
90
+ root: Path,
91
+ config: ProjectConfig,
92
+ *,
93
+ name: str,
94
+ label: str | None = None,
95
+ field_type: str = "SINGLE_LINE",
96
+ required: bool = False,
97
+ help_text: str | None = None,
98
+ default_value: Any = None,
99
+ force: bool = False,
100
+ ) -> Path:
101
+ """Write a settingsSchema field JSON under ``src/app/settings/``."""
102
+ base = settings_dir(root, config.srcDir)
103
+ base.mkdir(parents=True, exist_ok=True)
104
+ from caraer_cli.project.settings_sync import setting_filename, sanitize_setting
105
+
106
+ payload = sanitize_setting(
107
+ {
108
+ "name": name.strip(),
109
+ "label": (label or name).strip(),
110
+ "type": field_type.strip().upper() or "SINGLE_LINE",
111
+ "required": required,
112
+ "helpText": help_text,
113
+ "defaultValue": default_value,
114
+ }
115
+ )
116
+ path = base / setting_filename(payload)
117
+ if path.exists() and not force:
118
+ raise FileExistsError(f"Setting file already exists: {path}. Use --force to overwrite.")
119
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
120
+ return path
121
+
122
+
123
+ def scaffold_pricing_plan(
124
+ root: Path,
125
+ config: ProjectConfig,
126
+ *,
127
+ title: str,
128
+ pricing_type: str = "FLAT",
129
+ price_per_unit: float | int | None = 0,
130
+ unit: str = "installations",
131
+ description: str | None = None,
132
+ force: bool = False,
133
+ ) -> Path:
134
+ """Write a pricing plan JSON under ``src/app/pricing/``."""
135
+ from caraer_cli.project.pricing_sync import pricing_filename, sanitize_pricing
136
+
137
+ base = pricing_dir(root, config.srcDir)
138
+ base.mkdir(parents=True, exist_ok=True)
139
+ ptype = pricing_type.strip().upper() or "FLAT"
140
+ payload: dict[str, Any] = {
141
+ "title": title.strip(),
142
+ "description": description,
143
+ "pricingType": ptype,
144
+ }
145
+ if ptype == "FLAT":
146
+ payload["pricePerUnit"] = 0 if price_per_unit is None else price_per_unit
147
+ payload["unit"] = unit.strip() or "installations"
148
+ else:
149
+ payload["tiers"] = [
150
+ {
151
+ "startUnits": 0,
152
+ "endUnits": None,
153
+ "pricePerMonth": 0,
154
+ "pricePerYear": 0,
155
+ "pricePerExtraUnit": 0,
156
+ }
157
+ ]
158
+ sanitized = sanitize_pricing(payload)
159
+ path = base / pricing_filename(sanitized)
160
+ if path.exists() and not force:
161
+ raise FileExistsError(f"Pricing file already exists: {path}. Use --force to overwrite.")
162
+ path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
163
+ return path
164
+
165
+
166
+ def scaffold_app_bar(
167
+ root: Path,
168
+ config: ProjectConfig,
169
+ *,
170
+ location: str,
171
+ label: str,
172
+ iframe_url: str | None = None,
173
+ function_name: str | None = None,
174
+ action_label: str | None = None,
175
+ force: bool = False,
176
+ ) -> Path:
177
+ """Write an app bar JSON under ``src/app/app-bars/``."""
178
+ from caraer_cli.project.app_bars_sync import (
179
+ ACTION_BASED_LOCATIONS,
180
+ app_bar_filename,
181
+ sanitize_app_bar,
182
+ )
183
+
184
+ base = app_bars_dir(root, config.srcDir)
185
+ base.mkdir(parents=True, exist_ok=True)
186
+ loc = location.strip().upper()
187
+ payload: dict[str, Any] = {
188
+ "location": loc,
189
+ "label": label.strip(),
190
+ }
191
+ if loc in ACTION_BASED_LOCATIONS:
192
+ payload["actionLabel"] = (action_label or label).strip()
193
+ payload["webhook"] = {
194
+ "topic": "app.bar.triggered",
195
+ "deliveryMode": "SERVERLESS",
196
+ "enabled": True,
197
+ "serverlessFunction": {
198
+ "name": (function_name or "on-app-bar").strip(),
199
+ },
200
+ }
201
+ else:
202
+ if not iframe_url or not iframe_url.strip():
203
+ raise ValueError("iframe_url is required for iframe app bar locations.")
204
+ payload["iframeUrl"] = iframe_url.strip()
205
+ sanitized = sanitize_app_bar(payload)
206
+ path = base / app_bar_filename(sanitized)
207
+ if path.exists() and not force:
208
+ raise FileExistsError(f"App bar file already exists: {path}. Use --force to overwrite.")
209
+ path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
210
+ return path
211
+
212
+
213
+ def scaffold_lifecycle_hook(
214
+ root: Path,
215
+ config: ProjectConfig,
216
+ *,
217
+ event: str,
218
+ function_name: str | None = None,
219
+ runtime: str | None = None,
220
+ create_function: bool = True,
221
+ delivery_mode: str = "SERVERLESS",
222
+ url: str | None = None,
223
+ enabled: bool = True,
224
+ force: bool = False,
225
+ ) -> dict[str, Any]:
226
+ """Write ``lifecycle/<event>.json`` and optionally scaffold ``functions/on-<event>/``."""
227
+ stem = event.strip().lower()
228
+ if stem not in LIFECYCLE_HOOKS:
229
+ raise ValueError(
230
+ f"Unknown lifecycle event '{event}'. "
231
+ f"Expected one of: {', '.join(sorted(LIFECYCLE_HOOKS))}."
232
+ )
233
+ manifest_key, topic = LIFECYCLE_HOOKS[stem]
234
+ mode = (delivery_mode or "SERVERLESS").strip().upper()
235
+ if mode not in {"SERVERLESS", "HTTP"}:
236
+ raise ValueError("delivery_mode must be SERVERLESS or HTTP")
237
+ fn_name = (function_name or f"on-{stem}").strip()
238
+ base = lifecycle_dir(root, config.srcDir)
239
+ base.mkdir(parents=True, exist_ok=True)
240
+ path = base / f"{stem}.json"
241
+ if path.exists() and not force:
242
+ raise FileExistsError(f"Lifecycle file already exists: {path}. Use --force to overwrite.")
243
+
244
+ function_folder = None
245
+ if mode == "SERVERLESS" and create_function:
246
+ resolved_runtime = (
247
+ runtime or config.resolved_runtime("nodejs22")
248
+ ).strip().lower()
249
+ function_folder = scaffold_function(
250
+ root,
251
+ config,
252
+ fn_name,
253
+ resolved_runtime,
254
+ description=f"Handle {topic}",
255
+ force=force,
256
+ )
257
+ _write_lifecycle_function_entry(
258
+ function_folder, resolved_runtime, stem=stem, topic=topic
259
+ )
260
+
261
+ payload: dict[str, Any] = {
262
+ "topic": topic,
263
+ "deliveryMode": mode,
264
+ "enabled": enabled,
265
+ }
266
+ if mode == "HTTP":
267
+ if not url or not url.strip():
268
+ raise ValueError("url is required for HTTP lifecycle hooks")
269
+ payload["url"] = url.strip()
270
+ else:
271
+ payload["serverlessFunction"] = {"name": fn_name}
272
+ from caraer_cli.project.json_schemas import LIFECYCLE_SCHEMA_URL, dump_json_with_schema
273
+
274
+ dump_json_with_schema(path, payload, LIFECYCLE_SCHEMA_URL)
275
+ return {
276
+ "event": stem,
277
+ "manifestKey": manifest_key,
278
+ "lifecycleFile": path,
279
+ "functionName": fn_name if mode == "SERVERLESS" else None,
280
+ "functionFolder": function_folder,
281
+ }
282
+
283
+
284
+ def _write_lifecycle_function_entry(
285
+ folder: Path, runtime: str, *, stem: str, topic: str
286
+ ) -> None:
287
+ """Overwrite the scaffold entry with a lifecycle-aware stub."""
288
+ from caraer_cli.project.schema import load_function_manifest
289
+
290
+ manifest = load_function_manifest(folder / "function.caraer.json")
291
+ entry = folder / manifest.resolved_entry()
292
+ if runtime.startswith("python"):
293
+ entry.write_text(
294
+ f'def handler(request):\n'
295
+ f' """Handle {topic} (app lifecycle)."""\n'
296
+ f' body = request.get("body") if isinstance(request, dict) else {{}}\n'
297
+ f' if isinstance(body, str):\n'
298
+ f' import json\n'
299
+ f' try:\n'
300
+ f' body = json.loads(body or "{{}}")\n'
301
+ f' except Exception:\n'
302
+ f' body = {{}}\n'
303
+ f' return {{\n'
304
+ f' "statusCode": 200,\n'
305
+ f' "body": {{\n'
306
+ f' "ok": True,\n'
307
+ f' "hook": "{stem}",\n'
308
+ f' "event": (body or {{}}).get("event"),\n'
309
+ f' "companyUuid": (body or {{}}).get("companyUuid"),\n'
310
+ f' "appUuid": (body or {{}}).get("appUuid"),\n'
311
+ f' }},\n'
312
+ f' }}\n',
313
+ encoding="utf-8",
314
+ )
315
+ else:
316
+ entry.write_text(
317
+ "/**\n"
318
+ f" * Handle {topic} (app lifecycle).\n"
319
+ " */\n"
320
+ "exports.handler = async (req, res) => {\n"
321
+ " const body =\n"
322
+ " typeof req.body === \"string\"\n"
323
+ " ? JSON.parse(req.body || \"{}\")\n"
324
+ " : req.body || {};\n"
325
+ " res.status(200).json({\n"
326
+ " ok: true,\n"
327
+ f' hook: "{stem}",\n'
328
+ " event: body.event || null,\n"
329
+ " companyUuid: body.companyUuid || null,\n"
330
+ " appUuid: body.appUuid || null,\n"
331
+ " });\n"
332
+ "};\n",
333
+ encoding="utf-8",
334
+ )
335
+
336
+
337
+ def scaffold_all_lifecycle_hooks(
338
+ root: Path,
339
+ config: ProjectConfig,
340
+ *,
341
+ runtime: str | None = None,
342
+ force: bool = False,
343
+ ) -> list[dict[str, Any]]:
344
+ """Ensure all four lifecycle JSON files + on-* functions exist."""
345
+ created: list[dict[str, Any]] = []
346
+ resolved_runtime = (runtime or config.resolved_runtime("nodejs22")).strip().lower()
347
+ for stem in ("install", "uninstall", "rotate", "update"):
348
+ path = lifecycle_dir(root, config.srcDir) / f"{stem}.json"
349
+ fn_name = f"on-{stem}"
350
+ fn_folder = functions_dir(root, config.srcDir) / fn_name
351
+ if force or not path.exists():
352
+ created.append(
353
+ scaffold_lifecycle_hook(
354
+ root,
355
+ config,
356
+ event=stem,
357
+ runtime=resolved_runtime,
358
+ create_function=True,
359
+ force=force,
360
+ )
361
+ )
362
+ continue
363
+ # Lifecycle JSON exists; still create a missing on-* function.
364
+ if not fn_folder.exists():
365
+ _, topic = LIFECYCLE_HOOKS[stem]
366
+ function_folder = scaffold_function(
367
+ root,
368
+ config,
369
+ fn_name,
370
+ resolved_runtime,
371
+ description=f"Handle {topic}",
372
+ force=False,
373
+ )
374
+ _write_lifecycle_function_entry(
375
+ function_folder, resolved_runtime, stem=stem, topic=topic
376
+ )
377
+ created.append(
378
+ {
379
+ "event": stem,
380
+ "manifestKey": LIFECYCLE_HOOKS[stem][0],
381
+ "lifecycleFile": path,
382
+ "functionName": fn_name,
383
+ "functionFolder": function_folder,
384
+ }
385
+ )
386
+ return created
387
+
388
+
389
+ GITIGNORE_CONTENTS = """\
390
+ .caraer/
391
+ .env
392
+ .env.*
393
+ !.env.example
394
+ __pycache__/
395
+ node_modules/
396
+ *.pyc
397
+ .DS_Store
398
+ """
399
+
400
+
401
+ def resolve_project_root(path: str | Path, *, create: bool = False) -> Path:
402
+ root = Path(path).expanduser().resolve()
403
+ if create:
404
+ root.mkdir(parents=True, exist_ok=True)
405
+ return root
406
+
407
+
408
+ def write_app_manifest(
409
+ root: Path,
410
+ payload: dict[str, Any],
411
+ *,
412
+ src_dir: str = "src",
413
+ include_examples: bool = True,
414
+ ) -> Path:
415
+ """Write the marketplace/app definition YAML (not function code)."""
416
+ from caraer_cli.project.paths import APP_MANIFEST_YAML, app_dir
417
+
418
+ functions_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
419
+ webhooks_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
420
+ lifecycle_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
421
+ settings_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
422
+ pricing_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
423
+ app_bars_dir(root, src_dir).mkdir(parents=True, exist_ok=True)
424
+ # Always write preferred YAML for scaffolds / local edits.
425
+ manifest = app_dir(root, src_dir) / APP_MANIFEST_YAML
426
+ manifest.parent.mkdir(parents=True, exist_ok=True)
427
+ manifest.write_text(
428
+ render_app_manifest(payload, include_examples=include_examples),
429
+ encoding="utf-8",
430
+ )
431
+ return manifest
432
+
433
+
434
+ def ensure_gitignore(root: Path) -> Path | None:
435
+ path = root / ".gitignore"
436
+ if path.exists():
437
+ return None
438
+ path.write_text(GITIGNORE_CONTENTS, encoding="utf-8")
439
+ return path
440
+
441
+
442
+ def scaffold_app_project(
443
+ root: Path | str,
444
+ *,
445
+ app_payload: dict[str, Any],
446
+ project_name: str | None = None,
447
+ app_uuid: str | None = None,
448
+ project_uuid: str | None = None,
449
+ src_dir: str = "src",
450
+ sample_function: str | None = "hello-world",
451
+ runtime: str = "nodejs22",
452
+ platform_version: str = PLATFORM_VERSION,
453
+ force: bool = False,
454
+ ) -> dict[str, Any]:
455
+ """
456
+ Create a local app folder:
457
+
458
+ <root>/
459
+ caraer.json
460
+ .gitignore
461
+ src/app/
462
+ app.caraer.yaml
463
+ lifecycle/{install,uninstall,rotate,update}.json
464
+ functions/on-{install,uninstall,rotate,update}/
465
+ functions/<sample>/ (optional)
466
+ webhooks/
467
+ """
468
+ project_root = resolve_project_root(root, create=True)
469
+ config_path = workspace_file(project_root)
470
+ if config_path.exists() and not force:
471
+ raise FileExistsError(
472
+ f"{WORKSPACE_FILE} already exists in {project_root}. Use --force to overwrite."
473
+ )
474
+
475
+ name = (project_name or str(app_payload.get("name") or project_root.name)).strip()
476
+ config = ProjectConfig(
477
+ platformVersion=platform_version,
478
+ name=name,
479
+ appUuid=app_uuid,
480
+ srcDir=src_dir,
481
+ runtime=runtime if platform_version == PLATFORM_VERSION else None,
482
+ )
483
+ save_project_config(config_path, config)
484
+ if project_uuid:
485
+ set_project_uuid(project_root, project_uuid)
486
+
487
+ manifest_payload = dict(app_payload)
488
+ # Keep V2 app runtime on the marketplace manifest as well as caraer.json.
489
+ if platform_version == PLATFORM_VERSION:
490
+ manifest_payload["runtime"] = runtime or manifest_payload.get("runtime") or "nodejs22"
491
+ elif "runtime" in manifest_payload:
492
+ # V1: runtime lives on each function, not the app document.
493
+ manifest_payload.pop("runtime", None)
494
+ if not manifest_payload.get("authMethod"):
495
+ manifest_payload["authMethod"] = "OAUTH2"
496
+ if not manifest_payload.get("oauthRedirectUris"):
497
+ manifest_payload["oauthRedirectUris"] = ["http://localhost:3000/oauth/callback"]
498
+
499
+ app_file = write_app_manifest(project_root, manifest_payload, src_dir=src_dir)
500
+ ensure_gitignore(project_root)
501
+
502
+ lifecycle_hooks = scaffold_all_lifecycle_hooks(
503
+ project_root,
504
+ config,
505
+ runtime=runtime,
506
+ force=force,
507
+ )
508
+
509
+ function_folder: Path | None = None
510
+ webhook_file: Path | None = None
511
+ if sample_function:
512
+ function_folder = scaffold_function(
513
+ project_root,
514
+ config,
515
+ sample_function,
516
+ runtime=runtime,
517
+ force=force,
518
+ )
519
+ webhook_file = scaffold_webhook(
520
+ project_root,
521
+ config,
522
+ function_name=sample_function,
523
+ force=force,
524
+ )
525
+
526
+ return {
527
+ "root": project_root,
528
+ "project_file": config_path,
529
+ "app_file": app_file,
530
+ "functions_dir": functions_dir(project_root, src_dir),
531
+ "webhooks_dir": webhooks_dir(project_root, src_dir),
532
+ "lifecycle_dir": lifecycle_dir(project_root, src_dir),
533
+ "lifecycle_hooks": lifecycle_hooks,
534
+ "sample_function": function_folder,
535
+ "sample_webhook": webhook_file,
536
+ "config": config,
537
+ }