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,1215 @@
|
|
|
1
|
+
"""Local validation for Caraer app workspaces (manifest, functions, webhooks, schedules, inbound)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from caraer_cli.local_app import load_local_app
|
|
12
|
+
from caraer_cli.project.inbound_sync import discover_local_inbound
|
|
13
|
+
from caraer_cli.project.paths import (
|
|
14
|
+
app_manifest_path,
|
|
15
|
+
functions_dir,
|
|
16
|
+
inbound_dir,
|
|
17
|
+
schedules_dir,
|
|
18
|
+
webhooks_dir,
|
|
19
|
+
)
|
|
20
|
+
from caraer_cli.project.schedules_sync import discover_local_schedules
|
|
21
|
+
from caraer_cli.project.schema import ProjectConfig, load_workspace
|
|
22
|
+
from caraer_cli.project.settings_sync import discover_local_settings
|
|
23
|
+
from caraer_cli.project.pricing_sync import discover_local_pricing, pricing_identity
|
|
24
|
+
from caraer_cli.project.app_bars_sync import discover_local_app_bars
|
|
25
|
+
from caraer_cli.project.lifecycle_sync import LIFECYCLE_HOOKS, discover_local_lifecycle
|
|
26
|
+
from caraer_cli.project.marketplace_assemble import assemble_local_manifest
|
|
27
|
+
from caraer_cli.project.sync import discover_local_functions, list_local_function_names
|
|
28
|
+
from caraer_cli.project.webhooks_sync import discover_local_webhooks
|
|
29
|
+
|
|
30
|
+
Severity = Literal["error", "warning"]
|
|
31
|
+
|
|
32
|
+
HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
33
|
+
UUID_RE = re.compile(
|
|
34
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
35
|
+
)
|
|
36
|
+
# 5–6 field cron (backend accepts both); keep loose.
|
|
37
|
+
CRON_RE = re.compile(r"^(\S+\s+){4,5}\S+$")
|
|
38
|
+
INBOUND_AUTH_MODES = frozenset({"NONE", "SHARED_SECRET", "INSTALLATION_TOKEN"})
|
|
39
|
+
SETTING_FIELD_TYPES = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"SINGLE_LINE",
|
|
42
|
+
"MULTI_LINE",
|
|
43
|
+
"SINGLE_SELECT",
|
|
44
|
+
"MULTI_SELECT",
|
|
45
|
+
"RECORD_SINGLE_SELECT",
|
|
46
|
+
"RECORD_MULTI_SELECT",
|
|
47
|
+
"OBJECT_SINGLE_SELECT",
|
|
48
|
+
"OBJECT_MULTI_SELECT",
|
|
49
|
+
"PROPERTY_SINGLE_SELECT",
|
|
50
|
+
"PROPERTY_MULTI_SELECT",
|
|
51
|
+
"SWITCH",
|
|
52
|
+
"MAPPING",
|
|
53
|
+
"SECRET",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
SELECT_FIELD_TYPES = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"SINGLE_SELECT",
|
|
59
|
+
"MULTI_SELECT",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
PRICING_TYPES = frozenset({"FLAT", "TIERED"})
|
|
63
|
+
APP_BAR_LOCATIONS = frozenset(
|
|
64
|
+
{"RECORD_PREVIEW", "RECORD_OVERVIEW", "RECORD_DETAIL", "TOOL_BAR", "TRAIT_BAR"}
|
|
65
|
+
)
|
|
66
|
+
ACTION_BASED_LOCATIONS = frozenset({"RECORD_PREVIEW", "RECORD_OVERVIEW"})
|
|
67
|
+
|
|
68
|
+
# Mirrors AppCategoryCatalog main keys + legacy aliases accepted by the backend.
|
|
69
|
+
MAIN_CATEGORIES: dict[str, frozenset[str]] = {
|
|
70
|
+
"productivity": frozenset(
|
|
71
|
+
{"agenda", "tasks", "notes", "documents", "files", "automation", "forms"}
|
|
72
|
+
),
|
|
73
|
+
"communication": frozenset(
|
|
74
|
+
{
|
|
75
|
+
"email",
|
|
76
|
+
"chat",
|
|
77
|
+
"meetings",
|
|
78
|
+
"calling",
|
|
79
|
+
"notifications",
|
|
80
|
+
"customer_conversations",
|
|
81
|
+
"social_messaging",
|
|
82
|
+
}
|
|
83
|
+
),
|
|
84
|
+
"recruitment": frozenset(
|
|
85
|
+
{
|
|
86
|
+
"ats",
|
|
87
|
+
"job_posting",
|
|
88
|
+
"sourcing",
|
|
89
|
+
"candidate_communication",
|
|
90
|
+
"career_sites",
|
|
91
|
+
"screening",
|
|
92
|
+
"interview_scheduling",
|
|
93
|
+
"analytics",
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
"hr": frozenset(
|
|
97
|
+
{
|
|
98
|
+
"hris",
|
|
99
|
+
"onboarding",
|
|
100
|
+
"payroll",
|
|
101
|
+
"contracts",
|
|
102
|
+
"learning",
|
|
103
|
+
"engagement",
|
|
104
|
+
"compliance",
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
"marketing": frozenset(
|
|
108
|
+
{
|
|
109
|
+
"email_marketing",
|
|
110
|
+
"social_media",
|
|
111
|
+
"campaigns",
|
|
112
|
+
"content",
|
|
113
|
+
"seo",
|
|
114
|
+
"advertising",
|
|
115
|
+
"automation",
|
|
116
|
+
"branding",
|
|
117
|
+
}
|
|
118
|
+
),
|
|
119
|
+
"sales": frozenset(
|
|
120
|
+
{
|
|
121
|
+
"crm",
|
|
122
|
+
"lead_generation",
|
|
123
|
+
"enrichment",
|
|
124
|
+
"outreach",
|
|
125
|
+
"proposals",
|
|
126
|
+
"account_management",
|
|
127
|
+
"analytics",
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
"data": frozenset(
|
|
131
|
+
{
|
|
132
|
+
"dashboards",
|
|
133
|
+
"reporting",
|
|
134
|
+
"spreadsheets",
|
|
135
|
+
"visualization",
|
|
136
|
+
"data_management",
|
|
137
|
+
"predictions",
|
|
138
|
+
}
|
|
139
|
+
),
|
|
140
|
+
"finance": frozenset(
|
|
141
|
+
{
|
|
142
|
+
"invoicing",
|
|
143
|
+
"payments",
|
|
144
|
+
"bookkeeping",
|
|
145
|
+
"expenses",
|
|
146
|
+
"budgeting",
|
|
147
|
+
"reporting",
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
"customer_support": frozenset(
|
|
151
|
+
{
|
|
152
|
+
"helpdesk",
|
|
153
|
+
"ticketing",
|
|
154
|
+
"live_chat",
|
|
155
|
+
"knowledge_base",
|
|
156
|
+
"feedback",
|
|
157
|
+
"analytics",
|
|
158
|
+
}
|
|
159
|
+
),
|
|
160
|
+
"developer_tools": frozenset(
|
|
161
|
+
{
|
|
162
|
+
"code",
|
|
163
|
+
"repositories",
|
|
164
|
+
"apis",
|
|
165
|
+
"webhooks",
|
|
166
|
+
"devops",
|
|
167
|
+
"monitoring",
|
|
168
|
+
"testing",
|
|
169
|
+
"no_code",
|
|
170
|
+
}
|
|
171
|
+
),
|
|
172
|
+
"education_and_learning": frozenset(
|
|
173
|
+
{
|
|
174
|
+
"courses",
|
|
175
|
+
"training",
|
|
176
|
+
"onboarding",
|
|
177
|
+
"coaching",
|
|
178
|
+
"assessments",
|
|
179
|
+
"knowledge_sharing",
|
|
180
|
+
}
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
LEGACY_MAIN_ALIASES = {
|
|
185
|
+
"hr_recruiting": "recruitment",
|
|
186
|
+
"recruiting": "recruitment",
|
|
187
|
+
"blog": "marketing",
|
|
188
|
+
"integrations": "developer_tools",
|
|
189
|
+
"analytics": "data",
|
|
190
|
+
"automation": "productivity",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
LEGACY_ONLY_MAIN = frozenset(
|
|
194
|
+
{"hr_recruiting", "integrations", "analytics", "automation", "blog"}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class ValidationIssue:
|
|
200
|
+
severity: Severity
|
|
201
|
+
path: str
|
|
202
|
+
message: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class ValidationReport:
|
|
207
|
+
ok: bool
|
|
208
|
+
root: str
|
|
209
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
210
|
+
functions: int = 0
|
|
211
|
+
webhooks: int = 0
|
|
212
|
+
schedules: int = 0
|
|
213
|
+
inbound: int = 0
|
|
214
|
+
oauth_providers: int = 0
|
|
215
|
+
settings: int = 0
|
|
216
|
+
pricing_plans: int = 0
|
|
217
|
+
app_bars: int = 0
|
|
218
|
+
lifecycle_hooks: int = 0
|
|
219
|
+
|
|
220
|
+
def to_dict(self) -> dict[str, Any]:
|
|
221
|
+
return {
|
|
222
|
+
"ok": self.ok,
|
|
223
|
+
"root": self.root,
|
|
224
|
+
"functions": self.functions,
|
|
225
|
+
"webhooks": self.webhooks,
|
|
226
|
+
"schedules": self.schedules,
|
|
227
|
+
"inbound": self.inbound,
|
|
228
|
+
"oauthProviders": self.oauth_providers,
|
|
229
|
+
"settings": self.settings,
|
|
230
|
+
"pricingPlans": self.pricing_plans,
|
|
231
|
+
"appBars": self.app_bars,
|
|
232
|
+
"lifecycleHooks": self.lifecycle_hooks,
|
|
233
|
+
"errors": sum(1 for i in self.issues if i.severity == "error"),
|
|
234
|
+
"warnings": sum(1 for i in self.issues if i.severity == "warning"),
|
|
235
|
+
"issues": [asdict(i) for i in self.issues],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _issue(
|
|
240
|
+
issues: list[ValidationIssue],
|
|
241
|
+
severity: Severity,
|
|
242
|
+
path: str,
|
|
243
|
+
message: str,
|
|
244
|
+
) -> None:
|
|
245
|
+
issues.append(ValidationIssue(severity=severity, path=path, message=message))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _normalize_category_key(raw: str) -> str:
|
|
249
|
+
text = raw.strip().lower().replace("&", "and")
|
|
250
|
+
text = re.sub(r"[^a-z0-9]+", "_", text)
|
|
251
|
+
return text.strip("_")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve_main_category(raw: str | None) -> str | None:
|
|
255
|
+
if not raw or not str(raw).strip():
|
|
256
|
+
return None
|
|
257
|
+
key = _normalize_category_key(str(raw))
|
|
258
|
+
if key in MAIN_CATEGORIES:
|
|
259
|
+
return key
|
|
260
|
+
alias = LEGACY_MAIN_ALIASES.get(key)
|
|
261
|
+
if alias and alias in MAIN_CATEGORIES:
|
|
262
|
+
return alias
|
|
263
|
+
if key in LEGACY_ONLY_MAIN:
|
|
264
|
+
return key
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _looks_like_url(value: str) -> bool:
|
|
269
|
+
try:
|
|
270
|
+
parsed = urlparse(value)
|
|
271
|
+
except ValueError:
|
|
272
|
+
return False
|
|
273
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def validate_local_app(
|
|
277
|
+
root: Path,
|
|
278
|
+
*,
|
|
279
|
+
strict: bool = False,
|
|
280
|
+
) -> ValidationReport:
|
|
281
|
+
"""Validate a local app workspace without calling the API."""
|
|
282
|
+
issues: list[ValidationIssue] = []
|
|
283
|
+
functions_count = 0
|
|
284
|
+
webhooks_count = 0
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
config = load_workspace(root)
|
|
288
|
+
except Exception as exc: # noqa: BLE001
|
|
289
|
+
_issue(issues, "error", "caraer.json", f"Invalid workspace config: {exc}")
|
|
290
|
+
return ValidationReport(
|
|
291
|
+
ok=False,
|
|
292
|
+
root=str(root),
|
|
293
|
+
issues=issues,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
manifest_path = app_manifest_path(root, config.srcDir)
|
|
297
|
+
if not manifest_path.is_file():
|
|
298
|
+
_issue(
|
|
299
|
+
issues,
|
|
300
|
+
"error",
|
|
301
|
+
str(manifest_path.relative_to(root)),
|
|
302
|
+
"Missing app manifest (app.caraer.yaml).",
|
|
303
|
+
)
|
|
304
|
+
return ValidationReport(ok=False, root=str(root), issues=issues, functions=0, webhooks=0)
|
|
305
|
+
|
|
306
|
+
rel_manifest = str(manifest_path.relative_to(root))
|
|
307
|
+
try:
|
|
308
|
+
manifest = load_local_app(manifest_path)
|
|
309
|
+
except Exception as exc: # noqa: BLE001
|
|
310
|
+
_issue(issues, "error", rel_manifest, f"Could not parse manifest: {exc}")
|
|
311
|
+
return ValidationReport(ok=False, root=str(root), issues=issues)
|
|
312
|
+
|
|
313
|
+
_validate_manifest(manifest, config, rel_manifest, issues)
|
|
314
|
+
functions_count = _validate_functions(root, config, issues)
|
|
315
|
+
webhooks_count = _validate_webhooks(root, config, issues)
|
|
316
|
+
schedules_count = _validate_schedules(root, config, issues)
|
|
317
|
+
inbound_count = _validate_inbound(root, config, issues)
|
|
318
|
+
oauth_count = _validate_oauth_providers(manifest, rel_manifest, issues)
|
|
319
|
+
function_names = set(list_local_function_names(root, config))
|
|
320
|
+
settings_count = _validate_settings(root, config, manifest, rel_manifest, issues)
|
|
321
|
+
pricing_count = _validate_pricing(root, config, manifest, rel_manifest, issues)
|
|
322
|
+
app_bars_count = _validate_app_bars(
|
|
323
|
+
root, config, manifest, rel_manifest, function_names, issues
|
|
324
|
+
)
|
|
325
|
+
lifecycle_count = _validate_lifecycle(
|
|
326
|
+
root, config, function_names, issues
|
|
327
|
+
)
|
|
328
|
+
_validate_against_json_schemas(root, config, issues)
|
|
329
|
+
|
|
330
|
+
error_count = sum(1 for i in issues if i.severity == "error")
|
|
331
|
+
warning_count = sum(1 for i in issues if i.severity == "warning")
|
|
332
|
+
ok = error_count == 0 and (not strict or warning_count == 0)
|
|
333
|
+
return ValidationReport(
|
|
334
|
+
ok=ok,
|
|
335
|
+
root=str(root),
|
|
336
|
+
issues=issues,
|
|
337
|
+
functions=functions_count,
|
|
338
|
+
webhooks=webhooks_count,
|
|
339
|
+
schedules=schedules_count,
|
|
340
|
+
inbound=inbound_count,
|
|
341
|
+
oauth_providers=oauth_count,
|
|
342
|
+
settings=settings_count,
|
|
343
|
+
pricing_plans=pricing_count,
|
|
344
|
+
app_bars=app_bars_count,
|
|
345
|
+
lifecycle_hooks=lifecycle_count,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _validate_manifest(
|
|
350
|
+
manifest: dict[str, Any],
|
|
351
|
+
config: ProjectConfig,
|
|
352
|
+
rel_manifest: str,
|
|
353
|
+
issues: list[ValidationIssue],
|
|
354
|
+
) -> None:
|
|
355
|
+
name = str(manifest.get("name") or "").strip()
|
|
356
|
+
label = str(manifest.get("label") or "").strip()
|
|
357
|
+
if not name:
|
|
358
|
+
_issue(issues, "error", f"{rel_manifest}:name", "name is required.")
|
|
359
|
+
if not label:
|
|
360
|
+
_issue(issues, "error", f"{rel_manifest}:label", "label is required.")
|
|
361
|
+
|
|
362
|
+
uuid = str(manifest.get("uuid") or "").strip()
|
|
363
|
+
if uuid and not UUID_RE.match(uuid):
|
|
364
|
+
_issue(issues, "error", f"{rel_manifest}:uuid", "uuid must be a valid UUID.")
|
|
365
|
+
if config.appUuid and uuid and config.appUuid != uuid:
|
|
366
|
+
_issue(
|
|
367
|
+
issues,
|
|
368
|
+
"warning",
|
|
369
|
+
f"{rel_manifest}:uuid",
|
|
370
|
+
f"Manifest uuid ({uuid}) differs from caraer.json appUuid ({config.appUuid}).",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
auth = str(manifest.get("authMethod") or "").strip().upper()
|
|
374
|
+
if not auth:
|
|
375
|
+
_issue(issues, "error", f"{rel_manifest}:authMethod", "authMethod is required.")
|
|
376
|
+
elif auth not in {"OAUTH2", "API_KEY"}:
|
|
377
|
+
_issue(
|
|
378
|
+
issues,
|
|
379
|
+
"error",
|
|
380
|
+
f"{rel_manifest}:authMethod",
|
|
381
|
+
"authMethod must be OAUTH2 or API_KEY.",
|
|
382
|
+
)
|
|
383
|
+
elif auth == "OAUTH2":
|
|
384
|
+
redirects = manifest.get("oauthRedirectUris")
|
|
385
|
+
if not isinstance(redirects, list) or not any(
|
|
386
|
+
isinstance(u, str) and u.strip() for u in redirects
|
|
387
|
+
):
|
|
388
|
+
_issue(
|
|
389
|
+
issues,
|
|
390
|
+
"error",
|
|
391
|
+
f"{rel_manifest}:oauthRedirectUris",
|
|
392
|
+
"OAUTH2 apps require at least one oauthRedirectUris entry.",
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
for idx, uri in enumerate(redirects):
|
|
396
|
+
if not isinstance(uri, str) or not _looks_like_url(uri.strip()):
|
|
397
|
+
_issue(
|
|
398
|
+
issues,
|
|
399
|
+
"error",
|
|
400
|
+
f"{rel_manifest}:oauthRedirectUris[{idx}]",
|
|
401
|
+
"Must be an http(s) URL.",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
runtime = str(manifest.get("runtime") or config.runtime or "").strip().lower()
|
|
405
|
+
if config.is_app_platform_v2():
|
|
406
|
+
if runtime not in {"nodejs22", "python312"}:
|
|
407
|
+
_issue(
|
|
408
|
+
issues,
|
|
409
|
+
"error",
|
|
410
|
+
f"{rel_manifest}:runtime",
|
|
411
|
+
"platformVersion 2026.2 requires runtime nodejs22 or python312.",
|
|
412
|
+
)
|
|
413
|
+
elif runtime and runtime not in {"nodejs22", "python312"}:
|
|
414
|
+
_issue(
|
|
415
|
+
issues,
|
|
416
|
+
"error",
|
|
417
|
+
f"{rel_manifest}:runtime",
|
|
418
|
+
"runtime must be nodejs22 or python312 when set.",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
details = manifest.get("details")
|
|
422
|
+
if details is None:
|
|
423
|
+
_issue(
|
|
424
|
+
issues,
|
|
425
|
+
"error",
|
|
426
|
+
f"{rel_manifest}:details",
|
|
427
|
+
"App details are required for public marketplace apps.",
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
if not isinstance(details, dict):
|
|
431
|
+
_issue(
|
|
432
|
+
issues,
|
|
433
|
+
"error",
|
|
434
|
+
f"{rel_manifest}:details",
|
|
435
|
+
"details must be an object.",
|
|
436
|
+
)
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
category_raw = details.get("category")
|
|
440
|
+
category = _resolve_main_category(str(category_raw) if category_raw is not None else None)
|
|
441
|
+
if not category_raw or not str(category_raw).strip():
|
|
442
|
+
_issue(issues, "error", f"{rel_manifest}:details.category", "category is required.")
|
|
443
|
+
elif category is None:
|
|
444
|
+
_issue(
|
|
445
|
+
issues,
|
|
446
|
+
"error",
|
|
447
|
+
f"{rel_manifest}:details.category",
|
|
448
|
+
f"Unknown category '{category_raw}'.",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
brand = str(details.get("brandColor") or "").strip()
|
|
452
|
+
if not brand:
|
|
453
|
+
_issue(issues, "error", f"{rel_manifest}:details.brandColor", "brandColor is required.")
|
|
454
|
+
elif not HEX_COLOR_RE.match(brand):
|
|
455
|
+
_issue(
|
|
456
|
+
issues,
|
|
457
|
+
"error",
|
|
458
|
+
f"{rel_manifest}:details.brandColor",
|
|
459
|
+
"brandColor must be a hex color like #E74363.",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
text_color = str(details.get("textColor") or "").strip()
|
|
463
|
+
if text_color and not HEX_COLOR_RE.match(text_color):
|
|
464
|
+
_issue(
|
|
465
|
+
issues,
|
|
466
|
+
"error",
|
|
467
|
+
f"{rel_manifest}:details.textColor",
|
|
468
|
+
"textColor must be a hex color like #FFFFFF.",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
subs = details.get("subcategories")
|
|
472
|
+
if subs is None:
|
|
473
|
+
subs_list: list[str] = []
|
|
474
|
+
elif not isinstance(subs, list):
|
|
475
|
+
_issue(
|
|
476
|
+
issues,
|
|
477
|
+
"error",
|
|
478
|
+
f"{rel_manifest}:details.subcategories",
|
|
479
|
+
"subcategories must be a list of strings.",
|
|
480
|
+
)
|
|
481
|
+
subs_list = []
|
|
482
|
+
else:
|
|
483
|
+
subs_list = [str(s).strip() for s in subs if s is not None and str(s).strip()]
|
|
484
|
+
|
|
485
|
+
if category and category in MAIN_CATEGORIES:
|
|
486
|
+
allowed = MAIN_CATEGORIES[category]
|
|
487
|
+
invalid = [
|
|
488
|
+
s for s in subs_list if _normalize_category_key(s) not in allowed
|
|
489
|
+
]
|
|
490
|
+
if invalid:
|
|
491
|
+
_issue(
|
|
492
|
+
issues,
|
|
493
|
+
"error",
|
|
494
|
+
f"{rel_manifest}:details.subcategories",
|
|
495
|
+
f"Invalid for '{category}': {', '.join(invalid)}.",
|
|
496
|
+
)
|
|
497
|
+
if not subs_list and category not in LEGACY_ONLY_MAIN:
|
|
498
|
+
_issue(
|
|
499
|
+
issues,
|
|
500
|
+
"warning",
|
|
501
|
+
f"{rel_manifest}:details.subcategories",
|
|
502
|
+
"Select at least one subcategory for marketplace listing.",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
description = str(details.get("description") or "").strip()
|
|
506
|
+
if not description:
|
|
507
|
+
_issue(
|
|
508
|
+
issues,
|
|
509
|
+
"warning",
|
|
510
|
+
f"{rel_manifest}:details.description",
|
|
511
|
+
"description is empty; required before marketplace submit.",
|
|
512
|
+
)
|
|
513
|
+
elif description.upper().startswith("TODO"):
|
|
514
|
+
_issue(
|
|
515
|
+
issues,
|
|
516
|
+
"warning",
|
|
517
|
+
f"{rel_manifest}:details.description",
|
|
518
|
+
"description still looks like a placeholder (TODO…).",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
url = str(details.get("url") or "").strip()
|
|
522
|
+
if url and not _looks_like_url(url):
|
|
523
|
+
_issue(
|
|
524
|
+
issues,
|
|
525
|
+
"error",
|
|
526
|
+
f"{rel_manifest}:details.url",
|
|
527
|
+
"url must be an http(s) URL when set.",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
image = str(details.get("image") or "").strip()
|
|
531
|
+
if image and not _looks_like_url(image):
|
|
532
|
+
_issue(
|
|
533
|
+
issues,
|
|
534
|
+
"error",
|
|
535
|
+
f"{rel_manifest}:details.image",
|
|
536
|
+
"image must be an http(s) URL when set.",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _validate_functions(
|
|
541
|
+
root: Path,
|
|
542
|
+
config: ProjectConfig,
|
|
543
|
+
issues: list[ValidationIssue],
|
|
544
|
+
) -> int:
|
|
545
|
+
base = functions_dir(root, config.srcDir)
|
|
546
|
+
if not base.is_dir():
|
|
547
|
+
_issue(
|
|
548
|
+
issues,
|
|
549
|
+
"warning",
|
|
550
|
+
str(base.relative_to(root)),
|
|
551
|
+
"No functions directory found.",
|
|
552
|
+
)
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
discovered = discover_local_functions(root, config)
|
|
557
|
+
except Exception as exc: # noqa: BLE001
|
|
558
|
+
_issue(issues, "error", str(base.relative_to(root)), str(exc))
|
|
559
|
+
return 0
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
manifest_runtime = str(
|
|
563
|
+
load_local_app(app_manifest_path(root, config.srcDir)).get("runtime") or ""
|
|
564
|
+
).strip().lower()
|
|
565
|
+
except Exception: # noqa: BLE001
|
|
566
|
+
manifest_runtime = ""
|
|
567
|
+
expected_runtime = manifest_runtime or str(config.resolved_runtime("") or "").strip().lower()
|
|
568
|
+
|
|
569
|
+
for child in sorted(base.iterdir()):
|
|
570
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
571
|
+
continue
|
|
572
|
+
manifest_file = child / "function.caraer.json"
|
|
573
|
+
if not manifest_file.is_file():
|
|
574
|
+
_issue(
|
|
575
|
+
issues,
|
|
576
|
+
"error",
|
|
577
|
+
str(manifest_file.relative_to(root)),
|
|
578
|
+
"Missing function.caraer.json.",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
for manifest, entry_path, _code, _source_files in discovered:
|
|
582
|
+
rel = f"functions/{manifest.name}"
|
|
583
|
+
if manifest.name != entry_path.parent.name:
|
|
584
|
+
_issue(
|
|
585
|
+
issues,
|
|
586
|
+
"warning",
|
|
587
|
+
rel,
|
|
588
|
+
f"function name '{manifest.name}' does not match folder '{entry_path.parent.name}'.",
|
|
589
|
+
)
|
|
590
|
+
if expected_runtime and config.is_app_platform_v2() and manifest.runtime != expected_runtime:
|
|
591
|
+
_issue(
|
|
592
|
+
issues,
|
|
593
|
+
"warning",
|
|
594
|
+
f"{rel}:runtime",
|
|
595
|
+
f"Function runtime '{manifest.runtime}' differs from app runtime '{expected_runtime}'.",
|
|
596
|
+
)
|
|
597
|
+
if not entry_path.is_file():
|
|
598
|
+
_issue(issues, "error", str(entry_path.relative_to(root)), "Missing entry file.")
|
|
599
|
+
|
|
600
|
+
if not discovered:
|
|
601
|
+
_issue(
|
|
602
|
+
issues,
|
|
603
|
+
"warning",
|
|
604
|
+
str(base.relative_to(root)),
|
|
605
|
+
"No local functions found.",
|
|
606
|
+
)
|
|
607
|
+
return len(discovered)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _validate_webhooks(
|
|
611
|
+
root: Path,
|
|
612
|
+
config: ProjectConfig,
|
|
613
|
+
issues: list[ValidationIssue],
|
|
614
|
+
) -> int:
|
|
615
|
+
base = webhooks_dir(root, config.srcDir)
|
|
616
|
+
if not base.is_dir():
|
|
617
|
+
return 0
|
|
618
|
+
|
|
619
|
+
function_names = set(list_local_function_names(root, config))
|
|
620
|
+
try:
|
|
621
|
+
local = discover_local_webhooks(root, config)
|
|
622
|
+
except Exception as exc: # noqa: BLE001
|
|
623
|
+
_issue(issues, "error", str(base.relative_to(root)), f"Could not read webhooks: {exc}")
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
for path, item in local:
|
|
627
|
+
rel = str(path.relative_to(root))
|
|
628
|
+
topic = str(item.get("topic") or "").strip()
|
|
629
|
+
if not topic:
|
|
630
|
+
_issue(issues, "error", f"{rel}:topic", "topic is required.")
|
|
631
|
+
|
|
632
|
+
mode = str(item.get("deliveryMode") or "").strip().upper()
|
|
633
|
+
if not mode:
|
|
634
|
+
_issue(issues, "error", f"{rel}:deliveryMode", "deliveryMode is required.")
|
|
635
|
+
elif mode not in {"HTTP", "SERVERLESS"}:
|
|
636
|
+
_issue(
|
|
637
|
+
issues,
|
|
638
|
+
"error",
|
|
639
|
+
f"{rel}:deliveryMode",
|
|
640
|
+
"deliveryMode must be HTTP or SERVERLESS.",
|
|
641
|
+
)
|
|
642
|
+
elif mode == "HTTP":
|
|
643
|
+
url = str(item.get("url") or "").strip()
|
|
644
|
+
if not url:
|
|
645
|
+
_issue(issues, "error", f"{rel}:url", "HTTP webhooks require url.")
|
|
646
|
+
elif not _looks_like_url(url):
|
|
647
|
+
_issue(issues, "error", f"{rel}:url", "url must be an http(s) URL.")
|
|
648
|
+
elif mode == "SERVERLESS":
|
|
649
|
+
sf = item.get("serverlessFunction")
|
|
650
|
+
if not isinstance(sf, dict) or not (sf.get("name") or sf.get("uuid")):
|
|
651
|
+
_issue(
|
|
652
|
+
issues,
|
|
653
|
+
"error",
|
|
654
|
+
f"{rel}:serverlessFunction",
|
|
655
|
+
"SERVERLESS webhooks require serverlessFunction.name or .uuid.",
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
name = str(sf.get("name") or "").strip()
|
|
659
|
+
if name and name not in function_names:
|
|
660
|
+
_issue(
|
|
661
|
+
issues,
|
|
662
|
+
"error",
|
|
663
|
+
f"{rel}:serverlessFunction.name",
|
|
664
|
+
f"Unknown local function '{name}'.",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
uuid = str(item.get("uuid") or "").strip()
|
|
668
|
+
if uuid and not UUID_RE.match(uuid):
|
|
669
|
+
_issue(issues, "error", f"{rel}:uuid", "uuid must be a valid UUID.")
|
|
670
|
+
|
|
671
|
+
return len(local)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _function_ref_ok(sf: Any, function_names: set[str], issues: list[ValidationIssue], rel: str) -> None:
|
|
675
|
+
if not isinstance(sf, dict) or not (sf.get("name") or sf.get("uuid")):
|
|
676
|
+
_issue(
|
|
677
|
+
issues,
|
|
678
|
+
"error",
|
|
679
|
+
f"{rel}:serverlessFunction",
|
|
680
|
+
"Requires serverlessFunction.name or .uuid.",
|
|
681
|
+
)
|
|
682
|
+
return
|
|
683
|
+
name = str(sf.get("name") or "").strip()
|
|
684
|
+
if name and name not in function_names:
|
|
685
|
+
_issue(
|
|
686
|
+
issues,
|
|
687
|
+
"error",
|
|
688
|
+
f"{rel}:serverlessFunction.name",
|
|
689
|
+
f"Unknown local function '{name}'.",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _validate_schedules(
|
|
694
|
+
root: Path,
|
|
695
|
+
config: ProjectConfig,
|
|
696
|
+
issues: list[ValidationIssue],
|
|
697
|
+
) -> int:
|
|
698
|
+
base = schedules_dir(root, config.srcDir)
|
|
699
|
+
if not base.is_dir():
|
|
700
|
+
return 0
|
|
701
|
+
function_names = set(list_local_function_names(root, config))
|
|
702
|
+
try:
|
|
703
|
+
local = discover_local_schedules(root, config)
|
|
704
|
+
except Exception as exc: # noqa: BLE001
|
|
705
|
+
_issue(issues, "error", str(base.relative_to(root)), f"Could not read schedules: {exc}")
|
|
706
|
+
return 0
|
|
707
|
+
|
|
708
|
+
for path, item in local:
|
|
709
|
+
rel = str(path.relative_to(root))
|
|
710
|
+
name = str(item.get("name") or "").strip()
|
|
711
|
+
if not name:
|
|
712
|
+
_issue(issues, "error", f"{rel}:name", "name is required.")
|
|
713
|
+
schedule = str(item.get("schedule") or "").strip()
|
|
714
|
+
if not schedule:
|
|
715
|
+
_issue(issues, "error", f"{rel}:schedule", "schedule (cron) is required.")
|
|
716
|
+
elif not CRON_RE.match(schedule):
|
|
717
|
+
_issue(
|
|
718
|
+
issues,
|
|
719
|
+
"warning",
|
|
720
|
+
f"{rel}:schedule",
|
|
721
|
+
"schedule does not look like a 5–6 field cron expression.",
|
|
722
|
+
)
|
|
723
|
+
_function_ref_ok(item.get("serverlessFunction"), function_names, issues, rel)
|
|
724
|
+
uuid = str(item.get("uuid") or "").strip()
|
|
725
|
+
if uuid and not UUID_RE.match(uuid):
|
|
726
|
+
_issue(issues, "error", f"{rel}:uuid", "uuid must be a valid UUID.")
|
|
727
|
+
return len(local)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _validate_inbound(
|
|
731
|
+
root: Path,
|
|
732
|
+
config: ProjectConfig,
|
|
733
|
+
issues: list[ValidationIssue],
|
|
734
|
+
) -> int:
|
|
735
|
+
base = inbound_dir(root, config.srcDir)
|
|
736
|
+
if not base.is_dir():
|
|
737
|
+
return 0
|
|
738
|
+
function_names = set(list_local_function_names(root, config))
|
|
739
|
+
try:
|
|
740
|
+
local = discover_local_inbound(root, config)
|
|
741
|
+
except Exception as exc: # noqa: BLE001
|
|
742
|
+
_issue(issues, "error", str(base.relative_to(root)), f"Could not read inbound: {exc}")
|
|
743
|
+
return 0
|
|
744
|
+
|
|
745
|
+
for path, item in local:
|
|
746
|
+
rel = str(path.relative_to(root))
|
|
747
|
+
name = str(item.get("name") or "").strip()
|
|
748
|
+
if not name:
|
|
749
|
+
_issue(issues, "error", f"{rel}:name", "name is required.")
|
|
750
|
+
auth = str(item.get("authMode") or "NONE").strip().upper()
|
|
751
|
+
if auth not in INBOUND_AUTH_MODES:
|
|
752
|
+
_issue(
|
|
753
|
+
issues,
|
|
754
|
+
"error",
|
|
755
|
+
f"{rel}:authMode",
|
|
756
|
+
f"authMode must be one of: {', '.join(sorted(INBOUND_AUTH_MODES))}.",
|
|
757
|
+
)
|
|
758
|
+
elif auth == "SHARED_SECRET" and not str(item.get("sharedSecret") or "").strip():
|
|
759
|
+
if not item.get("hasSharedSecret"):
|
|
760
|
+
_issue(
|
|
761
|
+
issues,
|
|
762
|
+
"warning",
|
|
763
|
+
f"{rel}:sharedSecret",
|
|
764
|
+
"SHARED_SECRET inbound routes should set sharedSecret before push.",
|
|
765
|
+
)
|
|
766
|
+
_function_ref_ok(item.get("serverlessFunction"), function_names, issues, rel)
|
|
767
|
+
uuid = str(item.get("uuid") or "").strip()
|
|
768
|
+
if uuid and not UUID_RE.match(uuid):
|
|
769
|
+
_issue(issues, "error", f"{rel}:uuid", "uuid must be a valid UUID.")
|
|
770
|
+
return len(local)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _validate_oauth_providers(
|
|
774
|
+
manifest: dict[str, Any],
|
|
775
|
+
rel_manifest: str,
|
|
776
|
+
issues: list[ValidationIssue],
|
|
777
|
+
) -> int:
|
|
778
|
+
providers = manifest.get("externalOAuthProviders") or []
|
|
779
|
+
if providers is None:
|
|
780
|
+
return 0
|
|
781
|
+
if not isinstance(providers, list):
|
|
782
|
+
_issue(
|
|
783
|
+
issues,
|
|
784
|
+
"error",
|
|
785
|
+
f"{rel_manifest}:externalOAuthProviders",
|
|
786
|
+
"externalOAuthProviders must be a list.",
|
|
787
|
+
)
|
|
788
|
+
return 0
|
|
789
|
+
|
|
790
|
+
for idx, item in enumerate(providers):
|
|
791
|
+
rel = f"{rel_manifest}:externalOAuthProviders[{idx}]"
|
|
792
|
+
if not isinstance(item, dict):
|
|
793
|
+
_issue(issues, "error", rel, "Provider must be an object.")
|
|
794
|
+
continue
|
|
795
|
+
name = str(item.get("name") or "").strip()
|
|
796
|
+
if not name:
|
|
797
|
+
_issue(issues, "error", f"{rel}:name", "name is required.")
|
|
798
|
+
if item.get("preset") is not None and str(item.get("preset") or "").strip():
|
|
799
|
+
_issue(
|
|
800
|
+
issues,
|
|
801
|
+
"warning",
|
|
802
|
+
f"{rel}:preset",
|
|
803
|
+
"preset is deprecated and ignored; set authorizeUrl and tokenUrl explicitly.",
|
|
804
|
+
)
|
|
805
|
+
for url_key in ("authorizeUrl", "tokenUrl"):
|
|
806
|
+
url = str(item.get(url_key) or "").strip()
|
|
807
|
+
if not url:
|
|
808
|
+
_issue(
|
|
809
|
+
issues,
|
|
810
|
+
"error",
|
|
811
|
+
f"{rel}:{url_key}",
|
|
812
|
+
f"{url_key} is required.",
|
|
813
|
+
)
|
|
814
|
+
elif not url.startswith("${") and not _looks_like_url(url):
|
|
815
|
+
_issue(
|
|
816
|
+
issues,
|
|
817
|
+
"error",
|
|
818
|
+
f"{rel}:{url_key}",
|
|
819
|
+
f"{url_key} must be an http(s) URL.",
|
|
820
|
+
)
|
|
821
|
+
logo = str(item.get("logo") or "").strip()
|
|
822
|
+
if not logo:
|
|
823
|
+
_issue(
|
|
824
|
+
issues,
|
|
825
|
+
"warning",
|
|
826
|
+
f"{rel}:logo",
|
|
827
|
+
"logo is recommended for the Connect button (http(s) image URL).",
|
|
828
|
+
)
|
|
829
|
+
elif not logo.startswith("${") and not _looks_like_url(logo):
|
|
830
|
+
_issue(
|
|
831
|
+
issues,
|
|
832
|
+
"error",
|
|
833
|
+
f"{rel}:logo",
|
|
834
|
+
"logo must be an http(s) URL.",
|
|
835
|
+
)
|
|
836
|
+
client_id = str(item.get("clientId") or "").strip()
|
|
837
|
+
if not client_id:
|
|
838
|
+
_issue(
|
|
839
|
+
issues,
|
|
840
|
+
"warning",
|
|
841
|
+
f"{rel}:clientId",
|
|
842
|
+
"clientId is empty; provider will be skipped on push.",
|
|
843
|
+
)
|
|
844
|
+
scopes = item.get("scopes")
|
|
845
|
+
if scopes is not None and not isinstance(scopes, list):
|
|
846
|
+
_issue(
|
|
847
|
+
issues,
|
|
848
|
+
"error",
|
|
849
|
+
f"{rel}:scopes",
|
|
850
|
+
"scopes must be a list of strings.",
|
|
851
|
+
)
|
|
852
|
+
uuid = str(item.get("uuid") or "").strip()
|
|
853
|
+
if uuid and not UUID_RE.match(uuid):
|
|
854
|
+
_issue(issues, "error", f"{rel}:uuid", "uuid must be a valid UUID.")
|
|
855
|
+
return len(providers)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _merged_settings(
|
|
859
|
+
root: Path, config: ProjectConfig, manifest: dict[str, Any]
|
|
860
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
861
|
+
assembled = assemble_local_manifest(
|
|
862
|
+
root, config, manifest, resolve_functions=False, strict_function_refs=False
|
|
863
|
+
)
|
|
864
|
+
items = assembled.get("settingsSchema") or []
|
|
865
|
+
out: list[tuple[str, dict[str, Any]]] = []
|
|
866
|
+
file_by_name = {
|
|
867
|
+
str(item.get("name") or ""): path
|
|
868
|
+
for path, item in discover_local_settings(root, config)
|
|
869
|
+
}
|
|
870
|
+
for item in items:
|
|
871
|
+
if not isinstance(item, dict):
|
|
872
|
+
continue
|
|
873
|
+
name = str(item.get("name") or "")
|
|
874
|
+
path = file_by_name.get(name)
|
|
875
|
+
rel = (
|
|
876
|
+
str(path.relative_to(root))
|
|
877
|
+
if path is not None
|
|
878
|
+
else "app.caraer.yaml:settingsSchema"
|
|
879
|
+
)
|
|
880
|
+
out.append((rel, item))
|
|
881
|
+
return out
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _validate_settings(
|
|
885
|
+
root: Path,
|
|
886
|
+
config: ProjectConfig,
|
|
887
|
+
manifest: dict[str, Any],
|
|
888
|
+
rel_manifest: str,
|
|
889
|
+
issues: list[ValidationIssue],
|
|
890
|
+
) -> int:
|
|
891
|
+
rows = _merged_settings(root, config, manifest)
|
|
892
|
+
seen: set[str] = set()
|
|
893
|
+
for rel, item in rows:
|
|
894
|
+
name = str(item.get("name") or "").strip()
|
|
895
|
+
if not name:
|
|
896
|
+
_issue(issues, "error", f"{rel}:name", "name is required.")
|
|
897
|
+
continue
|
|
898
|
+
key = name.lower()
|
|
899
|
+
if key in seen:
|
|
900
|
+
_issue(issues, "error", f"{rel}:name", f"Duplicate setting name '{name}'.")
|
|
901
|
+
seen.add(key)
|
|
902
|
+
field_type = str(item.get("type") or "").strip().upper()
|
|
903
|
+
if not field_type:
|
|
904
|
+
_issue(issues, "error", f"{rel}:type", "type is required.")
|
|
905
|
+
elif field_type not in SETTING_FIELD_TYPES:
|
|
906
|
+
_issue(
|
|
907
|
+
issues,
|
|
908
|
+
"error",
|
|
909
|
+
f"{rel}:type",
|
|
910
|
+
f"Unknown type '{field_type}'.",
|
|
911
|
+
)
|
|
912
|
+
elif field_type in SELECT_FIELD_TYPES:
|
|
913
|
+
options = item.get("options")
|
|
914
|
+
options_source = item.get("optionsSource")
|
|
915
|
+
if not options and not options_source:
|
|
916
|
+
_issue(
|
|
917
|
+
issues,
|
|
918
|
+
"error",
|
|
919
|
+
f"{rel}:options",
|
|
920
|
+
"SELECT fields require options or optionsSource.",
|
|
921
|
+
)
|
|
922
|
+
return len(rows)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _validate_pricing(
|
|
926
|
+
root: Path,
|
|
927
|
+
config: ProjectConfig,
|
|
928
|
+
manifest: dict[str, Any],
|
|
929
|
+
rel_manifest: str,
|
|
930
|
+
issues: list[ValidationIssue],
|
|
931
|
+
) -> int:
|
|
932
|
+
assembled = assemble_local_manifest(
|
|
933
|
+
root, config, manifest, resolve_functions=False, strict_function_refs=False
|
|
934
|
+
)
|
|
935
|
+
items = assembled.get("pricingPlans") or []
|
|
936
|
+
file_items = {pricing_identity(i): p for p, i in discover_local_pricing(root, config)}
|
|
937
|
+
count = 0
|
|
938
|
+
for item in items:
|
|
939
|
+
if not isinstance(item, dict):
|
|
940
|
+
continue
|
|
941
|
+
count += 1
|
|
942
|
+
ident = pricing_identity(item)
|
|
943
|
+
path = file_items.get(ident)
|
|
944
|
+
rel = (
|
|
945
|
+
str(path.relative_to(root))
|
|
946
|
+
if path is not None
|
|
947
|
+
else f"{rel_manifest}:pricingPlans"
|
|
948
|
+
)
|
|
949
|
+
title = str(item.get("title") or item.get("name") or "").strip()
|
|
950
|
+
if not title:
|
|
951
|
+
_issue(issues, "error", f"{rel}:title", "title is required.")
|
|
952
|
+
pricing_type = str(item.get("pricingType") or "").strip().upper()
|
|
953
|
+
if pricing_type not in PRICING_TYPES:
|
|
954
|
+
_issue(
|
|
955
|
+
issues,
|
|
956
|
+
"error",
|
|
957
|
+
f"{rel}:pricingType",
|
|
958
|
+
"pricingType must be FLAT or TIERED.",
|
|
959
|
+
)
|
|
960
|
+
elif pricing_type == "FLAT":
|
|
961
|
+
if item.get("pricePerUnit") is None:
|
|
962
|
+
_issue(
|
|
963
|
+
issues,
|
|
964
|
+
"error",
|
|
965
|
+
f"{rel}:pricePerUnit",
|
|
966
|
+
"FLAT plans require pricePerUnit.",
|
|
967
|
+
)
|
|
968
|
+
if not str(item.get("unit") or "").strip():
|
|
969
|
+
_issue(issues, "error", f"{rel}:unit", "FLAT plans require unit.")
|
|
970
|
+
elif pricing_type == "TIERED":
|
|
971
|
+
tiers = item.get("tiers")
|
|
972
|
+
if not isinstance(tiers, list) or not tiers:
|
|
973
|
+
_issue(
|
|
974
|
+
issues,
|
|
975
|
+
"error",
|
|
976
|
+
f"{rel}:tiers",
|
|
977
|
+
"TIERED plans require a non-empty tiers list.",
|
|
978
|
+
)
|
|
979
|
+
return count
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _validate_app_bars(
|
|
983
|
+
root: Path,
|
|
984
|
+
config: ProjectConfig,
|
|
985
|
+
manifest: dict[str, Any],
|
|
986
|
+
rel_manifest: str,
|
|
987
|
+
function_names: set[str],
|
|
988
|
+
issues: list[ValidationIssue],
|
|
989
|
+
) -> int:
|
|
990
|
+
assembled = assemble_local_manifest(
|
|
991
|
+
root, config, manifest, resolve_functions=False, strict_function_refs=False
|
|
992
|
+
)
|
|
993
|
+
items = assembled.get("appBars") or []
|
|
994
|
+
file_by_id = {
|
|
995
|
+
f"{str(i.get('location') or '').upper()}|{str(i.get('label') or '').lower()}": p
|
|
996
|
+
for p, i in discover_local_app_bars(root, config)
|
|
997
|
+
}
|
|
998
|
+
count = 0
|
|
999
|
+
for item in items:
|
|
1000
|
+
if not isinstance(item, dict):
|
|
1001
|
+
continue
|
|
1002
|
+
count += 1
|
|
1003
|
+
location = str(item.get("location") or "").strip().upper()
|
|
1004
|
+
label = str(item.get("label") or "").strip()
|
|
1005
|
+
key = f"{location}|{label.lower()}"
|
|
1006
|
+
path = file_by_id.get(key)
|
|
1007
|
+
rel = (
|
|
1008
|
+
str(path.relative_to(root))
|
|
1009
|
+
if path is not None
|
|
1010
|
+
else f"{rel_manifest}:appBars"
|
|
1011
|
+
)
|
|
1012
|
+
if not label:
|
|
1013
|
+
_issue(issues, "error", f"{rel}:label", "label is required.")
|
|
1014
|
+
if location not in APP_BAR_LOCATIONS:
|
|
1015
|
+
_issue(
|
|
1016
|
+
issues,
|
|
1017
|
+
"error",
|
|
1018
|
+
f"{rel}:location",
|
|
1019
|
+
f"location must be one of: {', '.join(sorted(APP_BAR_LOCATIONS))}.",
|
|
1020
|
+
)
|
|
1021
|
+
continue
|
|
1022
|
+
if location in ACTION_BASED_LOCATIONS:
|
|
1023
|
+
webhook = item.get("webhook")
|
|
1024
|
+
if not isinstance(webhook, dict):
|
|
1025
|
+
_issue(
|
|
1026
|
+
issues,
|
|
1027
|
+
"warning",
|
|
1028
|
+
f"{rel}:webhook",
|
|
1029
|
+
"Action app bars usually define a webhook.",
|
|
1030
|
+
)
|
|
1031
|
+
else:
|
|
1032
|
+
_validate_sf_ref(
|
|
1033
|
+
webhook.get("serverlessFunction"),
|
|
1034
|
+
function_names,
|
|
1035
|
+
issues,
|
|
1036
|
+
f"{rel}:webhook",
|
|
1037
|
+
)
|
|
1038
|
+
else:
|
|
1039
|
+
iframe = str(item.get("iframeUrl") or "").strip()
|
|
1040
|
+
if not iframe:
|
|
1041
|
+
_issue(
|
|
1042
|
+
issues,
|
|
1043
|
+
"error",
|
|
1044
|
+
f"{rel}:iframeUrl",
|
|
1045
|
+
"Iframe app bars require iframeUrl.",
|
|
1046
|
+
)
|
|
1047
|
+
elif not iframe.startswith("${") and not _looks_like_url(iframe):
|
|
1048
|
+
_issue(
|
|
1049
|
+
issues,
|
|
1050
|
+
"error",
|
|
1051
|
+
f"{rel}:iframeUrl",
|
|
1052
|
+
"iframeUrl must be an http(s) URL.",
|
|
1053
|
+
)
|
|
1054
|
+
return count
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _validate_sf_ref(
|
|
1058
|
+
sf: Any,
|
|
1059
|
+
function_names: set[str],
|
|
1060
|
+
issues: list[ValidationIssue],
|
|
1061
|
+
rel: str,
|
|
1062
|
+
) -> None:
|
|
1063
|
+
if not isinstance(sf, dict):
|
|
1064
|
+
return
|
|
1065
|
+
name = str(sf.get("name") or "").strip()
|
|
1066
|
+
if name and name not in function_names:
|
|
1067
|
+
_issue(
|
|
1068
|
+
issues,
|
|
1069
|
+
"error",
|
|
1070
|
+
f"{rel}:serverlessFunction",
|
|
1071
|
+
f"Unknown local function '{name}'.",
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _validate_against_json_schemas(
|
|
1076
|
+
root: Path,
|
|
1077
|
+
config: ProjectConfig,
|
|
1078
|
+
issues: list[ValidationIssue],
|
|
1079
|
+
) -> None:
|
|
1080
|
+
"""Optional structural checks via jsonschema when the extra is installed."""
|
|
1081
|
+
try:
|
|
1082
|
+
import jsonschema
|
|
1083
|
+
except ImportError:
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
from caraer_cli.project.json_schemas import SCHEMA_FILENAMES, schemas_dir
|
|
1087
|
+
from caraer_cli.project.paths import lifecycle_dir
|
|
1088
|
+
|
|
1089
|
+
schema_root = schemas_dir()
|
|
1090
|
+
if schema_root is None:
|
|
1091
|
+
return
|
|
1092
|
+
|
|
1093
|
+
import json
|
|
1094
|
+
|
|
1095
|
+
cache: dict[str, Any] = {}
|
|
1096
|
+
|
|
1097
|
+
def load_schema(kind: str) -> Any | None:
|
|
1098
|
+
if kind in cache:
|
|
1099
|
+
return cache[kind]
|
|
1100
|
+
filename = SCHEMA_FILENAMES.get(kind)
|
|
1101
|
+
if not filename:
|
|
1102
|
+
return None
|
|
1103
|
+
path = schema_root / filename
|
|
1104
|
+
if not path.is_file():
|
|
1105
|
+
return None
|
|
1106
|
+
try:
|
|
1107
|
+
schema = json.loads(path.read_text(encoding="utf-8"))
|
|
1108
|
+
except Exception: # noqa: BLE001
|
|
1109
|
+
return None
|
|
1110
|
+
cache[kind] = schema
|
|
1111
|
+
return schema
|
|
1112
|
+
|
|
1113
|
+
def check(kind: str, rel: str, data: Any) -> None:
|
|
1114
|
+
schema = load_schema(kind)
|
|
1115
|
+
if schema is None or not isinstance(data, dict):
|
|
1116
|
+
return
|
|
1117
|
+
payload = {k: v for k, v in data.items() if k != "$schema"}
|
|
1118
|
+
try:
|
|
1119
|
+
jsonschema.validate(instance=payload, schema=schema)
|
|
1120
|
+
except jsonschema.ValidationError as exc:
|
|
1121
|
+
path_bits = ".".join(str(p) for p in exc.absolute_path) if exc.absolute_path else ""
|
|
1122
|
+
loc = f"{rel}:{path_bits}" if path_bits else rel
|
|
1123
|
+
_issue(
|
|
1124
|
+
issues,
|
|
1125
|
+
"warning",
|
|
1126
|
+
loc,
|
|
1127
|
+
f"JSON Schema: {exc.message}",
|
|
1128
|
+
)
|
|
1129
|
+
except Exception as exc: # noqa: BLE001
|
|
1130
|
+
_issue(issues, "warning", rel, f"JSON Schema check failed: {exc}")
|
|
1131
|
+
|
|
1132
|
+
try:
|
|
1133
|
+
manifest = load_local_app(app_manifest_path(root, config.srcDir))
|
|
1134
|
+
check("app", str(app_manifest_path(root, config.srcDir).relative_to(root)), manifest)
|
|
1135
|
+
except Exception: # noqa: BLE001
|
|
1136
|
+
pass
|
|
1137
|
+
|
|
1138
|
+
for manifest, _entry, _code, _sources in discover_local_functions(root, config):
|
|
1139
|
+
fn_path = functions_dir(root, config.srcDir) / manifest.name / "function.caraer.json"
|
|
1140
|
+
if fn_path.is_file():
|
|
1141
|
+
try:
|
|
1142
|
+
data = json.loads(fn_path.read_text(encoding="utf-8"))
|
|
1143
|
+
except Exception: # noqa: BLE001
|
|
1144
|
+
continue
|
|
1145
|
+
check("function", str(fn_path.relative_to(root)), data)
|
|
1146
|
+
|
|
1147
|
+
for path, item in discover_local_webhooks(root, config):
|
|
1148
|
+
check("webhook", str(path.relative_to(root)), item)
|
|
1149
|
+
for path, item in discover_local_schedules(root, config):
|
|
1150
|
+
check("schedule", str(path.relative_to(root)), item)
|
|
1151
|
+
for path, item in discover_local_inbound(root, config):
|
|
1152
|
+
check("inbound", str(path.relative_to(root)), item)
|
|
1153
|
+
|
|
1154
|
+
hooks = discover_local_lifecycle(root, config)
|
|
1155
|
+
base = lifecycle_dir(root, config.srcDir)
|
|
1156
|
+
for stem, (manifest_key, _topic) in LIFECYCLE_HOOKS.items():
|
|
1157
|
+
hook = hooks.get(manifest_key)
|
|
1158
|
+
if hook is None:
|
|
1159
|
+
continue
|
|
1160
|
+
check("lifecycle", str((base / f"{stem}.json").relative_to(root)), hook)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _validate_lifecycle(
|
|
1164
|
+
root: Path,
|
|
1165
|
+
config: ProjectConfig,
|
|
1166
|
+
function_names: set[str],
|
|
1167
|
+
issues: list[ValidationIssue],
|
|
1168
|
+
) -> int:
|
|
1169
|
+
from caraer_cli.project.paths import lifecycle_dir
|
|
1170
|
+
|
|
1171
|
+
hooks = discover_local_lifecycle(root, config)
|
|
1172
|
+
base = lifecycle_dir(root, config.srcDir)
|
|
1173
|
+
count = 0
|
|
1174
|
+
for stem, (manifest_key, expected_topic) in LIFECYCLE_HOOKS.items():
|
|
1175
|
+
hook = hooks.get(manifest_key)
|
|
1176
|
+
if hook is None:
|
|
1177
|
+
continue
|
|
1178
|
+
count += 1
|
|
1179
|
+
rel = str((base / f"{stem}.json").relative_to(root))
|
|
1180
|
+
topic = str(hook.get("topic") or "").strip()
|
|
1181
|
+
if topic and topic != expected_topic:
|
|
1182
|
+
_issue(
|
|
1183
|
+
issues,
|
|
1184
|
+
"error",
|
|
1185
|
+
f"{rel}:topic",
|
|
1186
|
+
f"topic must be '{expected_topic}' for {stem}.json.",
|
|
1187
|
+
)
|
|
1188
|
+
mode = str(hook.get("deliveryMode") or "SERVERLESS").strip().upper()
|
|
1189
|
+
if mode not in {"SERVERLESS", "HTTP"}:
|
|
1190
|
+
_issue(
|
|
1191
|
+
issues,
|
|
1192
|
+
"error",
|
|
1193
|
+
f"{rel}:deliveryMode",
|
|
1194
|
+
"deliveryMode must be SERVERLESS or HTTP.",
|
|
1195
|
+
)
|
|
1196
|
+
elif mode == "SERVERLESS":
|
|
1197
|
+
sf = hook.get("serverlessFunction")
|
|
1198
|
+
if not isinstance(sf, dict) or not (
|
|
1199
|
+
sf.get("name") or sf.get("uuid")
|
|
1200
|
+
):
|
|
1201
|
+
_issue(
|
|
1202
|
+
issues,
|
|
1203
|
+
"error",
|
|
1204
|
+
f"{rel}:serverlessFunction",
|
|
1205
|
+
"SERVERLESS lifecycle hooks require serverlessFunction.name.",
|
|
1206
|
+
)
|
|
1207
|
+
else:
|
|
1208
|
+
_validate_sf_ref(sf, function_names, issues, rel)
|
|
1209
|
+
elif mode == "HTTP":
|
|
1210
|
+
url = str(hook.get("url") or "").strip()
|
|
1211
|
+
if not url:
|
|
1212
|
+
_issue(issues, "error", f"{rel}:url", "HTTP hooks require url.")
|
|
1213
|
+
elif not _looks_like_url(url):
|
|
1214
|
+
_issue(issues, "error", f"{rel}:url", "url must be an http(s) URL.")
|
|
1215
|
+
return count
|