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,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
|
+
}
|