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,2025 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from caraer_cli.api import apps as apps_api
|
|
9
|
+
from caraer_cli.completion_callbacks import (
|
|
10
|
+
complete_app_list_type,
|
|
11
|
+
complete_app_ref,
|
|
12
|
+
complete_auth_method,
|
|
13
|
+
complete_local_function,
|
|
14
|
+
complete_runtime,
|
|
15
|
+
)
|
|
16
|
+
from caraer_cli.context import AppContext
|
|
17
|
+
from caraer_cli.formatters.output import print_app_detail, print_data, print_logs, print_success, project_rows
|
|
18
|
+
from caraer_cli.local_app import (
|
|
19
|
+
discover_local_app_files,
|
|
20
|
+
load_local_app,
|
|
21
|
+
local_app_summary,
|
|
22
|
+
looks_like_local_app_ref,
|
|
23
|
+
resolve_app_file_path,
|
|
24
|
+
)
|
|
25
|
+
from caraer_cli.resolve import resolve_app_file, resolve_app_uuid
|
|
26
|
+
from caraer_cli.state.config import save_config
|
|
27
|
+
from caraer_cli.utils import parse_patch
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(help="App lifecycle commands.", no_args_is_help=True)
|
|
30
|
+
|
|
31
|
+
# Nested installation runtime command groups.
|
|
32
|
+
from caraer_cli.commands import installation as installation_cmds # noqa: E402
|
|
33
|
+
|
|
34
|
+
app.add_typer(installation_cmds.state_app, name="state")
|
|
35
|
+
app.add_typer(installation_cmds.secrets_app, name="secrets")
|
|
36
|
+
app.add_typer(installation_cmds.jobs_app, name="jobs")
|
|
37
|
+
app.add_typer(installation_cmds.connections_app, name="connections")
|
|
38
|
+
|
|
39
|
+
DEFAULT_BRAND_COLOR = "#E74363"
|
|
40
|
+
DEFAULT_TEXT_COLOR = "#FFFFFF"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_app_name(value: str) -> str:
|
|
44
|
+
import re
|
|
45
|
+
|
|
46
|
+
normalized = value.strip().lower()
|
|
47
|
+
normalized = re.sub(r"[^a-z0-9]+", "_", normalized)
|
|
48
|
+
normalized = re.sub(r"_+", "_", normalized).strip("_")
|
|
49
|
+
return normalized
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
DEFAULT_OAUTH_CALLBACK = "http://localhost:3000/oauth/callback"
|
|
53
|
+
AUTH_METHODS = frozenset({"OAUTH2", "API_KEY"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_public_app_placeholder(
|
|
57
|
+
*,
|
|
58
|
+
label: str,
|
|
59
|
+
name: str | None = None,
|
|
60
|
+
runtime: str = "nodejs22",
|
|
61
|
+
auth_method: str = "OAUTH2",
|
|
62
|
+
oauth_redirect_uris: list[str] | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""Build a placeholder public-app payload for local editing.
|
|
65
|
+
|
|
66
|
+
Always includes runtime, authMethod, and oauthRedirectUris.
|
|
67
|
+
"""
|
|
68
|
+
resolved_label = label.strip() or "My App"
|
|
69
|
+
resolved_name = normalize_app_name(name or resolved_label) or "my_app"
|
|
70
|
+
method = (auth_method or "OAUTH2").strip().upper()
|
|
71
|
+
if method not in AUTH_METHODS:
|
|
72
|
+
raise ValueError(f"authMethod must be one of: {', '.join(sorted(AUTH_METHODS))}")
|
|
73
|
+
redirects = [
|
|
74
|
+
uri.strip()
|
|
75
|
+
for uri in (oauth_redirect_uris or [DEFAULT_OAUTH_CALLBACK])
|
|
76
|
+
if uri and uri.strip()
|
|
77
|
+
]
|
|
78
|
+
if method == "OAUTH2" and not redirects:
|
|
79
|
+
raise ValueError("OAUTH2 apps require at least one oauth redirect / callback URL.")
|
|
80
|
+
return {
|
|
81
|
+
"label": resolved_label,
|
|
82
|
+
"name": resolved_name,
|
|
83
|
+
"runtime": runtime,
|
|
84
|
+
"authMethod": method,
|
|
85
|
+
"oauthRedirectUris": redirects,
|
|
86
|
+
"details": {
|
|
87
|
+
"title": resolved_label,
|
|
88
|
+
"description": "TODO: short marketplace description",
|
|
89
|
+
"category": "developer_tools",
|
|
90
|
+
"subcategories": ["apis"],
|
|
91
|
+
"url": "https://example.com",
|
|
92
|
+
"image": "",
|
|
93
|
+
"brandColor": DEFAULT_BRAND_COLOR,
|
|
94
|
+
"textColor": DEFAULT_TEXT_COLOR,
|
|
95
|
+
},
|
|
96
|
+
"requiredScopes": [],
|
|
97
|
+
"settingsSchema": [],
|
|
98
|
+
"pricingPlans": [],
|
|
99
|
+
"appBars": [],
|
|
100
|
+
"webhookRateLimitPerMinute": 100,
|
|
101
|
+
"billFailedWebhookRequests": False,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _save_selection(
|
|
106
|
+
app_ctx: AppContext,
|
|
107
|
+
*,
|
|
108
|
+
app_uuid: str | None = None,
|
|
109
|
+
app_file: str | None = None,
|
|
110
|
+
clear_uuid: bool = False,
|
|
111
|
+
clear_file: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
profile = app_ctx.config.profiles[app_ctx.profile_name]
|
|
114
|
+
if clear_uuid:
|
|
115
|
+
profile.app_uuid = None
|
|
116
|
+
elif app_uuid is not None:
|
|
117
|
+
profile.app_uuid = app_uuid
|
|
118
|
+
if clear_file:
|
|
119
|
+
profile.app_file = None
|
|
120
|
+
elif app_file is not None:
|
|
121
|
+
profile.app_file = app_file
|
|
122
|
+
save_config(app_ctx.config)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _select_local_file(app_ctx: AppContext, path: str | Path) -> Path:
|
|
126
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
127
|
+
from caraer_cli.project.schema import load_workspace
|
|
128
|
+
|
|
129
|
+
resolved = resolve_app_file_path(path)
|
|
130
|
+
payload = load_local_app(resolved)
|
|
131
|
+
embedded_uuid = payload.get("uuid")
|
|
132
|
+
uuid_value = str(embedded_uuid).strip() if embedded_uuid else None
|
|
133
|
+
if not uuid_value:
|
|
134
|
+
try:
|
|
135
|
+
workspace_uuid = load_workspace(resolve_app_root(app_file=resolved)).appUuid
|
|
136
|
+
if workspace_uuid:
|
|
137
|
+
uuid_value = str(workspace_uuid).strip() or None
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
pass
|
|
140
|
+
_save_selection(
|
|
141
|
+
app_ctx,
|
|
142
|
+
app_file=str(resolved),
|
|
143
|
+
app_uuid=uuid_value,
|
|
144
|
+
clear_uuid=not bool(uuid_value),
|
|
145
|
+
)
|
|
146
|
+
if uuid_value:
|
|
147
|
+
print_success(
|
|
148
|
+
f"Selected local app '{resolved}' (uuid {uuid_value}) "
|
|
149
|
+
f"for profile '{app_ctx.profile_name}'."
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
print_success(
|
|
153
|
+
f"Selected local app '{resolved}' for profile '{app_ctx.profile_name}' "
|
|
154
|
+
"(no uuid yet — run 'caraer apps push' to create it)."
|
|
155
|
+
)
|
|
156
|
+
return resolved
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _select_remote_uuid(app_ctx: AppContext, app_uuid: str) -> Path:
|
|
160
|
+
from caraer_cli.app_sync import pull_app_full
|
|
161
|
+
|
|
162
|
+
# Validate + download full local editable copy, then select both uuid and file.
|
|
163
|
+
result = pull_app_full(
|
|
164
|
+
app_ctx.api_client(),
|
|
165
|
+
app_uuid=app_uuid,
|
|
166
|
+
existing_app_file=app_ctx.profile.app_file,
|
|
167
|
+
force=True,
|
|
168
|
+
)
|
|
169
|
+
app_file = Path(result["app_file"])
|
|
170
|
+
selected_uuid = str(result.get("appUuid") or app_uuid)
|
|
171
|
+
_save_selection(app_ctx, app_uuid=selected_uuid, app_file=str(app_file))
|
|
172
|
+
print_success(
|
|
173
|
+
f"Selected app '{selected_uuid}' for profile '{app_ctx.profile_name}' "
|
|
174
|
+
f"and pulled local folder '{result['root']}'."
|
|
175
|
+
)
|
|
176
|
+
return app_file
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _interactive_select(app_ctx: AppContext) -> None:
|
|
180
|
+
from questionary import Choice
|
|
181
|
+
|
|
182
|
+
from caraer_cli.wizard.prompts import WizardCancelled, ask_select
|
|
183
|
+
|
|
184
|
+
choices: list[Choice] = []
|
|
185
|
+
for path in discover_local_app_files():
|
|
186
|
+
try:
|
|
187
|
+
summary = local_app_summary(path)
|
|
188
|
+
except Exception:
|
|
189
|
+
summary = {"name": None, "label": path.name, "uuid": None}
|
|
190
|
+
label = summary.get("label") or summary.get("name") or path.name
|
|
191
|
+
uuid_hint = f", uuid {summary['uuid']}" if summary.get("uuid") else ", no uuid yet"
|
|
192
|
+
choices.append(
|
|
193
|
+
Choice(
|
|
194
|
+
title=f"[local] {label} — {path.name}{uuid_hint}",
|
|
195
|
+
value=f"local:{path}",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
response = apps_api.list_apps(
|
|
201
|
+
app_ctx.api_client(),
|
|
202
|
+
app_type="my",
|
|
203
|
+
page=1,
|
|
204
|
+
limit=50,
|
|
205
|
+
)
|
|
206
|
+
rows = response.get("data") if isinstance(response.get("data"), list) else []
|
|
207
|
+
except Exception as exc: # noqa: BLE001 - keep picker usable offline
|
|
208
|
+
rows = []
|
|
209
|
+
print_success(f"Could not load remote apps ({exc}); showing local files only.")
|
|
210
|
+
|
|
211
|
+
for row in rows:
|
|
212
|
+
if not isinstance(row, dict) or not row.get("uuid"):
|
|
213
|
+
continue
|
|
214
|
+
title = row.get("label") or row.get("name") or row["uuid"]
|
|
215
|
+
choices.append(
|
|
216
|
+
Choice(
|
|
217
|
+
title=f"[remote] {title} — {row['uuid']}",
|
|
218
|
+
value=f"remote:{row['uuid']}",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not choices:
|
|
223
|
+
raise typer.BadParameter(
|
|
224
|
+
"No local app files or remote apps found. "
|
|
225
|
+
"Run 'caraer apps init' / 'caraer apps wizard', or create an app remotely."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
selected = ask_select("Select an app", choices)
|
|
230
|
+
except WizardCancelled:
|
|
231
|
+
raise typer.Exit(code=1) from None
|
|
232
|
+
|
|
233
|
+
if selected.startswith("local:"):
|
|
234
|
+
_select_local_file(app_ctx, selected.removeprefix("local:"))
|
|
235
|
+
return
|
|
236
|
+
_select_remote_uuid(app_ctx, selected.removeprefix("remote:"))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command("list")
|
|
240
|
+
def list_apps(
|
|
241
|
+
ctx: typer.Context,
|
|
242
|
+
type: str = typer.Option(
|
|
243
|
+
"my",
|
|
244
|
+
"--type",
|
|
245
|
+
help="One of: my, private, public, native, local. Defaults to your created apps.",
|
|
246
|
+
autocompletion=complete_app_list_type,
|
|
247
|
+
),
|
|
248
|
+
page: int = typer.Option(1, "--page", help="Page number."),
|
|
249
|
+
limit: int = typer.Option(25, "--limit", help="Page size."),
|
|
250
|
+
query: str = typer.Option("", "--query", help="Optional search query."),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""List apps for the selected company (or local app files with --type local)."""
|
|
253
|
+
app_ctx: AppContext = ctx.obj
|
|
254
|
+
if type == "local":
|
|
255
|
+
rows = []
|
|
256
|
+
for path in discover_local_app_files():
|
|
257
|
+
try:
|
|
258
|
+
rows.append(local_app_summary(path))
|
|
259
|
+
except Exception as exc: # noqa: BLE001
|
|
260
|
+
rows.append({"source": "local", "path": str(path), "error": str(exc)})
|
|
261
|
+
print_data(rows, app_ctx.output)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
allowed_types = {"my", "private", "public", "native"}
|
|
265
|
+
if type not in allowed_types:
|
|
266
|
+
raise typer.BadParameter(
|
|
267
|
+
"Invalid --type. Use one of: my, private, public, native, local."
|
|
268
|
+
)
|
|
269
|
+
response = apps_api.list_apps(
|
|
270
|
+
app_ctx.api_client(),
|
|
271
|
+
app_type=type,
|
|
272
|
+
page=page,
|
|
273
|
+
limit=limit,
|
|
274
|
+
query=query or None,
|
|
275
|
+
)
|
|
276
|
+
rows = response.get("data")
|
|
277
|
+
if app_ctx.output == "table" and isinstance(rows, list):
|
|
278
|
+
rows = project_rows(
|
|
279
|
+
rows,
|
|
280
|
+
[
|
|
281
|
+
"uuid",
|
|
282
|
+
"name",
|
|
283
|
+
"label",
|
|
284
|
+
"platformVersion",
|
|
285
|
+
"privateApp",
|
|
286
|
+
"installed",
|
|
287
|
+
"authMethod",
|
|
288
|
+
"updatedAt",
|
|
289
|
+
],
|
|
290
|
+
)
|
|
291
|
+
print_data(rows, app_ctx.output)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command("select")
|
|
295
|
+
def select_app(
|
|
296
|
+
ctx: typer.Context,
|
|
297
|
+
target: str | None = typer.Argument(
|
|
298
|
+
None,
|
|
299
|
+
help="App UUID or path to a local app file. Omit to pick interactively.",
|
|
300
|
+
autocompletion=complete_app_ref,
|
|
301
|
+
),
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Select a remote app UUID and/or a local app file for the active profile."""
|
|
304
|
+
app_ctx: AppContext = ctx.obj
|
|
305
|
+
if not target:
|
|
306
|
+
_interactive_select(app_ctx)
|
|
307
|
+
return
|
|
308
|
+
if looks_like_local_app_ref(target):
|
|
309
|
+
_select_local_file(app_ctx, target)
|
|
310
|
+
return
|
|
311
|
+
_select_remote_uuid(app_ctx, target)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("clear")
|
|
315
|
+
def clear_app(ctx: typer.Context) -> None:
|
|
316
|
+
"""Clear the selected app UUID and local app file from the active profile."""
|
|
317
|
+
app_ctx: AppContext = ctx.obj
|
|
318
|
+
_save_selection(app_ctx, clear_uuid=True, clear_file=True)
|
|
319
|
+
print_success(f"Cleared selected app for profile '{app_ctx.profile_name}'.")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command("current")
|
|
323
|
+
def current_app(ctx: typer.Context) -> None:
|
|
324
|
+
"""Show the currently selected remote app and/or local app file."""
|
|
325
|
+
app_ctx: AppContext = ctx.obj
|
|
326
|
+
payload: dict[str, Any] = {
|
|
327
|
+
"profile": app_ctx.profile_name,
|
|
328
|
+
"app_uuid": app_ctx.profile.app_uuid,
|
|
329
|
+
"app_file": app_ctx.profile.app_file,
|
|
330
|
+
"company_uuid": app_ctx.profile.company_uuid,
|
|
331
|
+
}
|
|
332
|
+
if app_ctx.profile.app_file:
|
|
333
|
+
try:
|
|
334
|
+
summary = local_app_summary(Path(app_ctx.profile.app_file))
|
|
335
|
+
payload["local"] = summary
|
|
336
|
+
except Exception as exc: # noqa: BLE001
|
|
337
|
+
payload["local_error"] = str(exc)
|
|
338
|
+
print_data(payload, app_ctx.output)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command("get")
|
|
342
|
+
def get_app(
|
|
343
|
+
ctx: typer.Context,
|
|
344
|
+
app_uuid: str | None = typer.Argument(None, help="App UUID (defaults to selected app)."),
|
|
345
|
+
public: bool = typer.Option(False, "--public"),
|
|
346
|
+
full: bool = typer.Option(False, "--full", help="Show the full payload instead of a summary."),
|
|
347
|
+
local: bool = typer.Option(False, "--local", help="Show the selected local app file instead."),
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Show a summary of an app (use --full for the complete payload)."""
|
|
350
|
+
app_ctx: AppContext = ctx.obj
|
|
351
|
+
if local or (not app_uuid and not app_ctx.profile.app_uuid and app_ctx.profile.app_file):
|
|
352
|
+
path = resolve_app_file(app_ctx, None)
|
|
353
|
+
data = load_local_app(path)
|
|
354
|
+
if app_ctx.output == "table" and isinstance(data, dict) and not full:
|
|
355
|
+
print_data(local_app_summary(path, data), app_ctx.output)
|
|
356
|
+
return
|
|
357
|
+
print_data(data, app_ctx.output)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
resolved = resolve_app_uuid(app_ctx, app_uuid)
|
|
361
|
+
if public:
|
|
362
|
+
response = apps_api.get_public_app(app_ctx.api_client(), resolved)
|
|
363
|
+
else:
|
|
364
|
+
response = apps_api.get_app(app_ctx.api_client(), resolved)
|
|
365
|
+
data = response.get("data")
|
|
366
|
+
if app_ctx.output == "table" and isinstance(data, dict):
|
|
367
|
+
print_app_detail(data, full=full)
|
|
368
|
+
return
|
|
369
|
+
print_data(data, app_ctx.output)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.command("init")
|
|
373
|
+
def init_app(
|
|
374
|
+
ctx: typer.Context,
|
|
375
|
+
dir: str | None = typer.Option(
|
|
376
|
+
None,
|
|
377
|
+
"--dir",
|
|
378
|
+
"-d",
|
|
379
|
+
help="App directory (defaults to normalized --name / --label).",
|
|
380
|
+
),
|
|
381
|
+
label: str = typer.Option("My App", "--label", help="Display label for the placeholder."),
|
|
382
|
+
name: str | None = typer.Option(None, "--name", help="App name (normalized). Defaults from --label."),
|
|
383
|
+
app_uuid: str | None = typer.Option(
|
|
384
|
+
None,
|
|
385
|
+
"--app-uuid",
|
|
386
|
+
"--app",
|
|
387
|
+
help="Link to an existing remote app after scaffolding.",
|
|
388
|
+
),
|
|
389
|
+
runtime: str = typer.Option(
|
|
390
|
+
"nodejs22",
|
|
391
|
+
"--runtime",
|
|
392
|
+
help="App / function runtime: nodejs22 or python312 (always written to the manifest).",
|
|
393
|
+
autocompletion=complete_runtime,
|
|
394
|
+
),
|
|
395
|
+
auth_method: str = typer.Option(
|
|
396
|
+
"OAUTH2",
|
|
397
|
+
"--auth-method",
|
|
398
|
+
help="App auth method: OAUTH2 or API_KEY (always written to the manifest).",
|
|
399
|
+
autocompletion=complete_auth_method,
|
|
400
|
+
),
|
|
401
|
+
oauth_redirect_uri: list[str] | None = typer.Option(
|
|
402
|
+
None,
|
|
403
|
+
"--oauth-redirect-uri",
|
|
404
|
+
"--callback-url",
|
|
405
|
+
help="OAuth callback / redirect URL (repeatable; required for OAUTH2).",
|
|
406
|
+
),
|
|
407
|
+
platform: str = typer.Option(
|
|
408
|
+
"2026.2",
|
|
409
|
+
"--platform",
|
|
410
|
+
help="Workspace platformVersion: 2026.2 (container/V2, default) or 2026.1 (legacy).",
|
|
411
|
+
),
|
|
412
|
+
function: str = typer.Option(
|
|
413
|
+
"hello-world",
|
|
414
|
+
"--function",
|
|
415
|
+
help="Sample function folder name (empty string to skip).",
|
|
416
|
+
),
|
|
417
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing app files."),
|
|
418
|
+
select: bool = typer.Option(True, "--select/--no-select", help="Select the new app file in the profile."),
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Create a local app folder (manifest + functions + webhooks)."""
|
|
421
|
+
from caraer_cli.api import projects as projects_api
|
|
422
|
+
from caraer_cli.app_sync import ensure_linked
|
|
423
|
+
from caraer_cli.errors import ApiError
|
|
424
|
+
from caraer_cli.project.scaffold import scaffold_app_project
|
|
425
|
+
from caraer_cli.project.schema import PLATFORM_VERSION, PLATFORM_VERSION_V1, load_workspace
|
|
426
|
+
|
|
427
|
+
app_ctx: AppContext = ctx.obj
|
|
428
|
+
if runtime not in {"nodejs22", "python312"}:
|
|
429
|
+
raise typer.BadParameter("--runtime must be nodejs22 or python312.")
|
|
430
|
+
if platform not in {PLATFORM_VERSION, PLATFORM_VERSION_V1}:
|
|
431
|
+
raise typer.BadParameter("--platform must be 2026.2 or 2026.1.")
|
|
432
|
+
try:
|
|
433
|
+
payload = build_public_app_placeholder(
|
|
434
|
+
label=label,
|
|
435
|
+
name=name,
|
|
436
|
+
runtime=runtime,
|
|
437
|
+
auth_method=auth_method,
|
|
438
|
+
oauth_redirect_uris=list(oauth_redirect_uri)
|
|
439
|
+
if oauth_redirect_uri
|
|
440
|
+
else [DEFAULT_OAUTH_CALLBACK],
|
|
441
|
+
)
|
|
442
|
+
except ValueError as exc:
|
|
443
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
444
|
+
# Legacy V1 apps keep runtime on functions only.
|
|
445
|
+
if platform == PLATFORM_VERSION_V1:
|
|
446
|
+
payload.pop("runtime", None)
|
|
447
|
+
project_name = payload["name"]
|
|
448
|
+
project_dir = Path(dir) if dir else Path(project_name)
|
|
449
|
+
linked_app = app_uuid or app_ctx.profile.app_uuid
|
|
450
|
+
project_uuid = None
|
|
451
|
+
|
|
452
|
+
if linked_app:
|
|
453
|
+
apps_api.get_app(app_ctx.api_client(), linked_app)
|
|
454
|
+
try:
|
|
455
|
+
response = projects_api.create_or_get_project(
|
|
456
|
+
app_ctx.api_client(), linked_app, project_name
|
|
457
|
+
)
|
|
458
|
+
data = response.get("data") or {}
|
|
459
|
+
project_uuid = data.get("uuid")
|
|
460
|
+
linked_app = data.get("appUuid") or linked_app
|
|
461
|
+
except ApiError:
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
result = scaffold_app_project(
|
|
466
|
+
project_dir,
|
|
467
|
+
app_payload=payload,
|
|
468
|
+
project_name=project_name,
|
|
469
|
+
app_uuid=linked_app,
|
|
470
|
+
project_uuid=project_uuid,
|
|
471
|
+
sample_function=(function.strip() or None),
|
|
472
|
+
runtime=runtime,
|
|
473
|
+
platform_version=platform,
|
|
474
|
+
force=force,
|
|
475
|
+
)
|
|
476
|
+
except FileExistsError as exc:
|
|
477
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
478
|
+
|
|
479
|
+
app_file: Path = result["app_file"]
|
|
480
|
+
if linked_app:
|
|
481
|
+
try:
|
|
482
|
+
ensure_linked(
|
|
483
|
+
app_ctx.api_client(),
|
|
484
|
+
result["root"],
|
|
485
|
+
load_workspace(result["root"]),
|
|
486
|
+
app_uuid=str(linked_app),
|
|
487
|
+
)
|
|
488
|
+
except Exception: # noqa: BLE001
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
print_success(f"Created app at {result['root']}")
|
|
492
|
+
print_data(
|
|
493
|
+
{
|
|
494
|
+
"root": str(result["root"]),
|
|
495
|
+
"app_file": str(app_file),
|
|
496
|
+
"functions_dir": str(result["functions_dir"]),
|
|
497
|
+
"webhooks_dir": str(result["webhooks_dir"]),
|
|
498
|
+
"lifecycle_dir": str(result["lifecycle_dir"]),
|
|
499
|
+
"lifecycle_hooks": [
|
|
500
|
+
h["event"] for h in (result.get("lifecycle_hooks") or [])
|
|
501
|
+
],
|
|
502
|
+
"sample_function": str(result["sample_function"]) if result["sample_function"] else None,
|
|
503
|
+
},
|
|
504
|
+
app_ctx.output,
|
|
505
|
+
)
|
|
506
|
+
if select:
|
|
507
|
+
_select_local_file(app_ctx, app_file)
|
|
508
|
+
print_success(
|
|
509
|
+
"Lifecycle hooks (install/uninstall/rotate/update) are under "
|
|
510
|
+
"src/app/lifecycle/ + functions/on-*. Edit files under src/app/, "
|
|
511
|
+
"then run: caraer apps push"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@app.command("wizard")
|
|
516
|
+
@app.command("create-public-wizard", hidden=True)
|
|
517
|
+
def wizard_app(
|
|
518
|
+
ctx: typer.Context,
|
|
519
|
+
dir: str | None = typer.Option(
|
|
520
|
+
None,
|
|
521
|
+
"--dir",
|
|
522
|
+
"-d",
|
|
523
|
+
help="Default app directory (confirmable in the wizard).",
|
|
524
|
+
),
|
|
525
|
+
force: bool = typer.Option(False, "--force", help="Overwrite without asking if files exist."),
|
|
526
|
+
select: bool = typer.Option(True, "--select/--no-select", help="Select the generated app file in the profile."),
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Interactively build a local app folder (manifest + functions + webhooks)."""
|
|
529
|
+
app_ctx: AppContext = ctx.obj
|
|
530
|
+
from caraer_cli.wizard.prompts import WizardCancelled
|
|
531
|
+
from caraer_cli.wizard.public_app import run_public_app_wizard
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
path = run_public_app_wizard(output_dir=dir, force=force)
|
|
535
|
+
except WizardCancelled:
|
|
536
|
+
raise typer.Exit(code=1) from None
|
|
537
|
+
if select:
|
|
538
|
+
_select_local_file(app_ctx, path)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@app.command("pull")
|
|
542
|
+
def pull_app(
|
|
543
|
+
ctx: typer.Context,
|
|
544
|
+
app_uuid: str | None = typer.Argument(None, help="App UUID (defaults to selected remote app)."),
|
|
545
|
+
file: str | None = typer.Option(
|
|
546
|
+
None,
|
|
547
|
+
"--file",
|
|
548
|
+
"-f",
|
|
549
|
+
help="Explicit path for app.caraer.yaml.",
|
|
550
|
+
),
|
|
551
|
+
dir: str | None = typer.Option(
|
|
552
|
+
None,
|
|
553
|
+
"--dir",
|
|
554
|
+
"-d",
|
|
555
|
+
help="Explicit app directory to write into (otherwise a new folder is created for a different app).",
|
|
556
|
+
),
|
|
557
|
+
force: bool = typer.Option(True, "--force/--no-force", help="Overwrite local files."),
|
|
558
|
+
select: bool = typer.Option(True, "--select/--no-select", help="Select the pulled file in the profile."),
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Pull a remote app into a local folder.
|
|
561
|
+
|
|
562
|
+
Refreshing the same selected app reuses that folder. Pulling a different
|
|
563
|
+
app creates a new folder named after the app (sibling of the current
|
|
564
|
+
project when you are inside one).
|
|
565
|
+
"""
|
|
566
|
+
from caraer_cli.app_sync import pull_app_full
|
|
567
|
+
|
|
568
|
+
app_ctx: AppContext = ctx.obj
|
|
569
|
+
resolved = resolve_app_uuid(app_ctx, app_uuid)
|
|
570
|
+
try:
|
|
571
|
+
result = pull_app_full(
|
|
572
|
+
app_ctx.api_client(),
|
|
573
|
+
app_uuid=resolved,
|
|
574
|
+
file=file,
|
|
575
|
+
directory=dir,
|
|
576
|
+
existing_app_file=None if file or dir else app_ctx.profile.app_file,
|
|
577
|
+
force=force,
|
|
578
|
+
)
|
|
579
|
+
except FileExistsError as exc:
|
|
580
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
581
|
+
|
|
582
|
+
selected_uuid = str(result.get("appUuid") or resolved)
|
|
583
|
+
if select:
|
|
584
|
+
_save_selection(app_ctx, app_uuid=selected_uuid, app_file=str(result["app_file"]))
|
|
585
|
+
print_success(f"Pulled app '{selected_uuid}' to {result['app_file']}")
|
|
586
|
+
print_data(result, app_ctx.output)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@app.command("push")
|
|
590
|
+
def push_public(
|
|
591
|
+
ctx: typer.Context,
|
|
592
|
+
app_uuid: str | None = typer.Argument(None, help="App UUID (defaults to selected app)."),
|
|
593
|
+
file: str | None = typer.Option(
|
|
594
|
+
None,
|
|
595
|
+
"--file",
|
|
596
|
+
"-f",
|
|
597
|
+
help="App folder or app.caraer.yaml (defaults to selected local app file / cwd).",
|
|
598
|
+
),
|
|
599
|
+
patch: str | None = typer.Option(None, "--patch", help="JSON patch merged onto the manifest."),
|
|
600
|
+
deploy: bool = typer.Option(False, "--deploy", help="Deploy after creating a function build."),
|
|
601
|
+
wait: bool = typer.Option(
|
|
602
|
+
True,
|
|
603
|
+
"--wait/--no-wait",
|
|
604
|
+
help="After --deploy on V2 apps, wait for runtimeStatus READY/FAILED (default: wait).",
|
|
605
|
+
),
|
|
606
|
+
version: str | None = typer.Option(
|
|
607
|
+
None,
|
|
608
|
+
"--version",
|
|
609
|
+
"-V",
|
|
610
|
+
help="Default release version shown in the prompt (must be > previous).",
|
|
611
|
+
),
|
|
612
|
+
release_notes: str | None = typer.Option(
|
|
613
|
+
None,
|
|
614
|
+
"--notes",
|
|
615
|
+
"--release-notes",
|
|
616
|
+
help="Default release notes shown in the prompt.",
|
|
617
|
+
),
|
|
618
|
+
yes: bool = typer.Option(
|
|
619
|
+
False,
|
|
620
|
+
"--yes",
|
|
621
|
+
"-y",
|
|
622
|
+
help="Non-interactive: skip confirmation; require --version and --notes.",
|
|
623
|
+
),
|
|
624
|
+
dry_run: bool = typer.Option(
|
|
625
|
+
False,
|
|
626
|
+
"--dry-run",
|
|
627
|
+
help="Show the create/update/delete plan and exit without writing.",
|
|
628
|
+
),
|
|
629
|
+
delete_missing: bool = typer.Option(
|
|
630
|
+
False,
|
|
631
|
+
"--delete-missing",
|
|
632
|
+
help="Delete remote functions/webhooks missing locally.",
|
|
633
|
+
),
|
|
634
|
+
legacy_functions: bool = typer.Option(
|
|
635
|
+
False,
|
|
636
|
+
"--legacy-functions",
|
|
637
|
+
help="Sync functions via create/update APIs instead of a build.",
|
|
638
|
+
),
|
|
639
|
+
target: str = typer.Option("production", "--target", help="production|sandbox"),
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Push the full local app (manifest, functions, webhooks, schedules, inbound, OAuth) to Caraer."""
|
|
642
|
+
import sys
|
|
643
|
+
|
|
644
|
+
from caraer_cli.app_sync import push_app, resolve_app_root
|
|
645
|
+
from caraer_cli.formatters.output import print_warning
|
|
646
|
+
from caraer_cli.project.push_plan import build_push_plan, print_push_plan
|
|
647
|
+
from caraer_cli.project.release import is_semver
|
|
648
|
+
from caraer_cli.project.schema import load_workspace
|
|
649
|
+
from caraer_cli.wizard.prompts import WizardCancelled, ask_confirm
|
|
650
|
+
|
|
651
|
+
app_ctx: AppContext = ctx.obj
|
|
652
|
+
if version is not None:
|
|
653
|
+
version = version.strip()
|
|
654
|
+
if not is_semver(version):
|
|
655
|
+
raise typer.BadParameter("--version must be MAJOR.MINOR.PATCH (e.g. 1.2.3).")
|
|
656
|
+
if yes and not dry_run and (not version or not (release_notes or "").strip()):
|
|
657
|
+
raise typer.BadParameter("--yes requires both --version and --notes.")
|
|
658
|
+
selected_file = file or app_ctx.profile.app_file
|
|
659
|
+
try:
|
|
660
|
+
root = resolve_app_root(app_file=selected_file)
|
|
661
|
+
except FileNotFoundError:
|
|
662
|
+
if selected_file:
|
|
663
|
+
raise typer.BadParameter(
|
|
664
|
+
"Could not resolve an app folder. Run 'caraer apps init' or select a local app."
|
|
665
|
+
) from None
|
|
666
|
+
raise typer.BadParameter(
|
|
667
|
+
"No app folder found. Run 'caraer apps init' or 'cd' into an app directory."
|
|
668
|
+
) from None
|
|
669
|
+
|
|
670
|
+
if target == "sandbox":
|
|
671
|
+
print_warning(
|
|
672
|
+
"Sandbox target isolates Neo4j data via X-Caraer-Sandbox-Uuid, "
|
|
673
|
+
"but function runtime code is shared with production."
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
client = app_ctx.api_client()
|
|
677
|
+
config = load_workspace(root)
|
|
678
|
+
if app_uuid:
|
|
679
|
+
config.appUuid = app_uuid
|
|
680
|
+
elif not config.appUuid and app_ctx.profile.app_uuid:
|
|
681
|
+
config.appUuid = app_ctx.profile.app_uuid
|
|
682
|
+
|
|
683
|
+
interactive_tty = sys.stdin.isatty() and sys.stdout.isatty()
|
|
684
|
+
show_plan = dry_run or (interactive_tty and not yes)
|
|
685
|
+
if show_plan:
|
|
686
|
+
plan = build_push_plan(client, root, config, delete_missing=delete_missing)
|
|
687
|
+
if app_ctx.output in {"json", "yaml"}:
|
|
688
|
+
print_data(plan, app_ctx.output)
|
|
689
|
+
else:
|
|
690
|
+
print_push_plan(plan)
|
|
691
|
+
if dry_run:
|
|
692
|
+
print_success("Dry run complete — no changes written.")
|
|
693
|
+
return
|
|
694
|
+
try:
|
|
695
|
+
if not ask_confirm("Proceed with push?", default=False):
|
|
696
|
+
print_warning("Push cancelled.")
|
|
697
|
+
raise typer.Exit(code=1)
|
|
698
|
+
except WizardCancelled:
|
|
699
|
+
raise typer.Exit(code=1) from None
|
|
700
|
+
|
|
701
|
+
patch_data = parse_patch(patch) if patch else None
|
|
702
|
+
try:
|
|
703
|
+
result = push_app(
|
|
704
|
+
client,
|
|
705
|
+
root,
|
|
706
|
+
app_uuid=app_uuid or app_ctx.profile.app_uuid,
|
|
707
|
+
patch=patch_data,
|
|
708
|
+
deploy=deploy,
|
|
709
|
+
delete_missing=delete_missing,
|
|
710
|
+
legacy_functions=legacy_functions,
|
|
711
|
+
target=target,
|
|
712
|
+
wait=wait,
|
|
713
|
+
version=version,
|
|
714
|
+
release_notes=release_notes,
|
|
715
|
+
interactive=not yes,
|
|
716
|
+
)
|
|
717
|
+
except WizardCancelled:
|
|
718
|
+
raise typer.Exit(code=1) from None
|
|
719
|
+
if result.get("appUuid"):
|
|
720
|
+
manifest = resolve_app_file_path(selected_file or root)
|
|
721
|
+
_save_selection(app_ctx, app_uuid=str(result["appUuid"]), app_file=str(manifest))
|
|
722
|
+
print_success("Pushed app (manifest + functions + webhooks).")
|
|
723
|
+
print_data(result, app_ctx.output)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def normalize_function_name(value: str) -> str:
|
|
727
|
+
import re
|
|
728
|
+
|
|
729
|
+
normalized = value.strip().lower()
|
|
730
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
|
731
|
+
normalized = re.sub(r"-+", "-", normalized).strip("-")
|
|
732
|
+
return normalized
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@app.command("add-function")
|
|
736
|
+
def add_function(
|
|
737
|
+
ctx: typer.Context,
|
|
738
|
+
name: str | None = typer.Argument(
|
|
739
|
+
None,
|
|
740
|
+
help="Function name / folder (e.g. hello-world). Prompted if omitted.",
|
|
741
|
+
),
|
|
742
|
+
runtime: str | None = typer.Option(
|
|
743
|
+
None,
|
|
744
|
+
"--runtime",
|
|
745
|
+
help="nodejs22 or python312 (defaults to caraer.json runtime or nodejs22).",
|
|
746
|
+
autocompletion=complete_runtime,
|
|
747
|
+
),
|
|
748
|
+
description: str = typer.Option("", "--description", help="Function description."),
|
|
749
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing scaffold files."),
|
|
750
|
+
) -> None:
|
|
751
|
+
"""Scaffold a local function folder under src/app/functions/<name>/."""
|
|
752
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
753
|
+
from caraer_cli.project.schema import load_workspace
|
|
754
|
+
from caraer_cli.project.sync import scaffold_function
|
|
755
|
+
from caraer_cli.wizard.prompts import require_text
|
|
756
|
+
|
|
757
|
+
app_ctx: AppContext = ctx.obj
|
|
758
|
+
name = require_text(name, "Function name", flag="name")
|
|
759
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
760
|
+
config = load_workspace(root)
|
|
761
|
+
normalized = normalize_function_name(name)
|
|
762
|
+
if not normalized:
|
|
763
|
+
raise ValueError("Function name must contain letters or digits.")
|
|
764
|
+
resolved_runtime = (runtime or config.resolved_runtime("nodejs22")).strip().lower()
|
|
765
|
+
if resolved_runtime not in {"nodejs22", "python312"}:
|
|
766
|
+
raise ValueError("runtime must be nodejs22 or python312")
|
|
767
|
+
try:
|
|
768
|
+
folder = scaffold_function(
|
|
769
|
+
root,
|
|
770
|
+
config,
|
|
771
|
+
normalized,
|
|
772
|
+
resolved_runtime,
|
|
773
|
+
description=description or normalized,
|
|
774
|
+
force=force,
|
|
775
|
+
)
|
|
776
|
+
except FileExistsError as exc:
|
|
777
|
+
raise ValueError(str(exc)) from None
|
|
778
|
+
print_success(f"Created function scaffold at {folder}")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@app.command("add-webhook")
|
|
782
|
+
def add_webhook(
|
|
783
|
+
ctx: typer.Context,
|
|
784
|
+
topic: str | None = typer.Option(
|
|
785
|
+
None,
|
|
786
|
+
"--topic",
|
|
787
|
+
"-t",
|
|
788
|
+
help="Webhook topic (e.g. record.created, app.bar.triggered). Prompted if omitted.",
|
|
789
|
+
),
|
|
790
|
+
function: str | None = typer.Option(
|
|
791
|
+
None,
|
|
792
|
+
"--function",
|
|
793
|
+
"-f",
|
|
794
|
+
help="Local function name for SERVERLESS delivery.",
|
|
795
|
+
autocompletion=complete_local_function,
|
|
796
|
+
),
|
|
797
|
+
mode: str = typer.Option(
|
|
798
|
+
"SERVERLESS",
|
|
799
|
+
"--mode",
|
|
800
|
+
help="SERVERLESS (default) or HTTP.",
|
|
801
|
+
),
|
|
802
|
+
url: str | None = typer.Option(None, "--url", help="Destination URL when --mode HTTP."),
|
|
803
|
+
webhook_format: str = typer.Option(
|
|
804
|
+
"USER_FRIENDLY",
|
|
805
|
+
"--format",
|
|
806
|
+
help="Webhook payload format (e.g. USER_FRIENDLY, RAW).",
|
|
807
|
+
),
|
|
808
|
+
description: str = typer.Option("", "--description", help="Webhook description."),
|
|
809
|
+
filename: str | None = typer.Option(
|
|
810
|
+
None,
|
|
811
|
+
"--filename",
|
|
812
|
+
help="Output file name under webhooks/ (default: <topic>-serverless.json).",
|
|
813
|
+
),
|
|
814
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing webhook file."),
|
|
815
|
+
) -> None:
|
|
816
|
+
"""Scaffold a local webhook JSON under src/app/webhooks/."""
|
|
817
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
818
|
+
from caraer_cli.project.scaffold import scaffold_webhook
|
|
819
|
+
from caraer_cli.project.schema import load_workspace
|
|
820
|
+
from caraer_cli.wizard.prompts import require_text
|
|
821
|
+
|
|
822
|
+
app_ctx: AppContext = ctx.obj
|
|
823
|
+
topic = require_text(topic, "Webhook topic", flag="--topic")
|
|
824
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
825
|
+
config = load_workspace(root)
|
|
826
|
+
resolved_mode = mode.strip().upper()
|
|
827
|
+
function_name = normalize_function_name(function) if function else None
|
|
828
|
+
if resolved_mode == "SERVERLESS" and not function_name:
|
|
829
|
+
function_name = normalize_function_name(
|
|
830
|
+
require_text(None, "Function name", flag="--function")
|
|
831
|
+
)
|
|
832
|
+
if resolved_mode == "HTTP" and not (url and url.strip()):
|
|
833
|
+
url = require_text(None, "Webhook URL", flag="--url")
|
|
834
|
+
if resolved_mode == "SERVERLESS" and not function_name:
|
|
835
|
+
raise ValueError("SERVERLESS webhooks require --function <name>.")
|
|
836
|
+
if resolved_mode == "HTTP" and not (url and url.strip()):
|
|
837
|
+
raise ValueError("HTTP webhooks require --url <https://...>.")
|
|
838
|
+
try:
|
|
839
|
+
path = scaffold_webhook(
|
|
840
|
+
root,
|
|
841
|
+
config,
|
|
842
|
+
topic=topic.strip(),
|
|
843
|
+
function_name=function_name,
|
|
844
|
+
delivery_mode=resolved_mode,
|
|
845
|
+
url=url,
|
|
846
|
+
webhook_format=webhook_format.strip() or "USER_FRIENDLY",
|
|
847
|
+
description=description or None,
|
|
848
|
+
filename=filename,
|
|
849
|
+
force=force,
|
|
850
|
+
)
|
|
851
|
+
except FileExistsError as exc:
|
|
852
|
+
raise ValueError(str(exc)) from None
|
|
853
|
+
print_success(f"Created webhook scaffold at {path}")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@app.command("add-schedule")
|
|
857
|
+
def add_schedule(
|
|
858
|
+
ctx: typer.Context,
|
|
859
|
+
name: str | None = typer.Argument(None, help="Schedule name (e.g. renew-gmail-watch)."),
|
|
860
|
+
function: str | None = typer.Option(
|
|
861
|
+
None,
|
|
862
|
+
"--function",
|
|
863
|
+
"-f",
|
|
864
|
+
help="Local function name to invoke.",
|
|
865
|
+
autocompletion=complete_local_function,
|
|
866
|
+
),
|
|
867
|
+
schedule: str = typer.Option(
|
|
868
|
+
"0 0 */6 * * *",
|
|
869
|
+
"--cron",
|
|
870
|
+
help="Spring 6-field cron expression.",
|
|
871
|
+
),
|
|
872
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing file."),
|
|
873
|
+
) -> None:
|
|
874
|
+
"""Scaffold a local schedule JSON under src/app/schedules/."""
|
|
875
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
876
|
+
from caraer_cli.project.paths import schedules_dir
|
|
877
|
+
from caraer_cli.project.schema import load_workspace
|
|
878
|
+
from caraer_cli.wizard.prompts import require_text
|
|
879
|
+
|
|
880
|
+
app_ctx: AppContext = ctx.obj
|
|
881
|
+
name = require_text(name, "Schedule name", flag="name")
|
|
882
|
+
function_name = normalize_function_name(
|
|
883
|
+
require_text(function, "Function name", flag="--function")
|
|
884
|
+
)
|
|
885
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
886
|
+
config = load_workspace(root)
|
|
887
|
+
base = schedules_dir(root, config.srcDir)
|
|
888
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
889
|
+
path = base / f"{normalize_function_name(name)}.json"
|
|
890
|
+
if path.exists() and not force:
|
|
891
|
+
raise ValueError(f"Schedule file already exists: {path}")
|
|
892
|
+
payload = {
|
|
893
|
+
"name": normalize_function_name(name).replace("-", "_"),
|
|
894
|
+
"schedule": schedule,
|
|
895
|
+
"enabled": True,
|
|
896
|
+
"serverlessFunction": {"name": function_name},
|
|
897
|
+
}
|
|
898
|
+
from caraer_cli.project.json_schemas import SCHEDULE_SCHEMA_URL, dump_json_with_schema
|
|
899
|
+
|
|
900
|
+
dump_json_with_schema(path, payload, SCHEDULE_SCHEMA_URL)
|
|
901
|
+
print_success(f"Created schedule scaffold at {path}")
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@app.command("add-inbound")
|
|
905
|
+
def add_inbound(
|
|
906
|
+
ctx: typer.Context,
|
|
907
|
+
name: str | None = typer.Argument(None, help="Inbound route name (e.g. gmail-push)."),
|
|
908
|
+
function: str | None = typer.Option(
|
|
909
|
+
None,
|
|
910
|
+
"--function",
|
|
911
|
+
"-f",
|
|
912
|
+
help="Local function name to invoke.",
|
|
913
|
+
autocompletion=complete_local_function,
|
|
914
|
+
),
|
|
915
|
+
auth: str = typer.Option(
|
|
916
|
+
"SHARED_SECRET",
|
|
917
|
+
"--auth",
|
|
918
|
+
help="NONE, SHARED_SECRET, or GOOGLE_OIDC.",
|
|
919
|
+
),
|
|
920
|
+
enqueue: bool = typer.Option(True, "--enqueue/--sync", help="Enqueue as app job (default) or sync invoke."),
|
|
921
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing file."),
|
|
922
|
+
) -> None:
|
|
923
|
+
"""Scaffold a local inbound route JSON under src/app/inbound/."""
|
|
924
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
925
|
+
from caraer_cli.project.paths import inbound_dir
|
|
926
|
+
from caraer_cli.project.schema import load_workspace
|
|
927
|
+
from caraer_cli.wizard.prompts import require_text
|
|
928
|
+
|
|
929
|
+
app_ctx: AppContext = ctx.obj
|
|
930
|
+
name = require_text(name, "Inbound route name", flag="name")
|
|
931
|
+
function_name = normalize_function_name(
|
|
932
|
+
require_text(function, "Function name", flag="--function")
|
|
933
|
+
)
|
|
934
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
935
|
+
config = load_workspace(root)
|
|
936
|
+
base = inbound_dir(root, config.srcDir)
|
|
937
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
938
|
+
path = base / f"{normalize_function_name(name)}.json"
|
|
939
|
+
if path.exists() and not force:
|
|
940
|
+
raise ValueError(f"Inbound file already exists: {path}")
|
|
941
|
+
payload = {
|
|
942
|
+
"name": normalize_function_name(name),
|
|
943
|
+
"authMode": auth.strip().upper(),
|
|
944
|
+
"enqueue": enqueue,
|
|
945
|
+
"serverlessFunction": {"name": function_name},
|
|
946
|
+
}
|
|
947
|
+
from caraer_cli.project.json_schemas import INBOUND_SCHEMA_URL, dump_json_with_schema
|
|
948
|
+
|
|
949
|
+
dump_json_with_schema(path, payload, INBOUND_SCHEMA_URL)
|
|
950
|
+
print_success(f"Created inbound scaffold at {path}")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
@app.command("add-setting")
|
|
954
|
+
def add_setting(
|
|
955
|
+
ctx: typer.Context,
|
|
956
|
+
name: str | None = typer.Argument(None, help="Setting field name (e.g. pubsub_topic)."),
|
|
957
|
+
label: str | None = typer.Option(None, "--label", help="Display label."),
|
|
958
|
+
field_type: str | None = typer.Option(None, "--type", help="Setting field type."),
|
|
959
|
+
required: bool | None = typer.Option(
|
|
960
|
+
None, "--required/--optional", help="Mark as required."
|
|
961
|
+
),
|
|
962
|
+
help_text: str | None = typer.Option(None, "--help-text", help="Help text for installers."),
|
|
963
|
+
default: str | None = typer.Option(None, "--default", help="Default value."),
|
|
964
|
+
modular: bool = typer.Option(
|
|
965
|
+
False,
|
|
966
|
+
"--modular",
|
|
967
|
+
help="Write src/app/settings/<name>.json instead of app.caraer.yaml.",
|
|
968
|
+
),
|
|
969
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing entry/file."),
|
|
970
|
+
) -> None:
|
|
971
|
+
"""Add a settingsSchema field to app.caraer.yaml (wizard prompts when interactive)."""
|
|
972
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
973
|
+
from caraer_cli.project.manifest_edit import append_manifest_list_item
|
|
974
|
+
from caraer_cli.project.scaffold import scaffold_setting
|
|
975
|
+
from caraer_cli.project.schema import load_workspace
|
|
976
|
+
from caraer_cli.project.settings_sync import sanitize_setting
|
|
977
|
+
from caraer_cli.wizard.marketplace import prompt_setting_field
|
|
978
|
+
|
|
979
|
+
app_ctx: AppContext = ctx.obj
|
|
980
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
981
|
+
config = load_workspace(root)
|
|
982
|
+
field = sanitize_setting(
|
|
983
|
+
prompt_setting_field(
|
|
984
|
+
name=name,
|
|
985
|
+
label=label,
|
|
986
|
+
field_type=field_type,
|
|
987
|
+
required=required,
|
|
988
|
+
help_text=help_text,
|
|
989
|
+
default_value=default,
|
|
990
|
+
)
|
|
991
|
+
)
|
|
992
|
+
try:
|
|
993
|
+
if modular:
|
|
994
|
+
path = scaffold_setting(
|
|
995
|
+
root,
|
|
996
|
+
config,
|
|
997
|
+
name=str(field["name"]),
|
|
998
|
+
label=field.get("label"),
|
|
999
|
+
field_type=str(field.get("type") or "SINGLE_LINE"),
|
|
1000
|
+
required=bool(field.get("required")),
|
|
1001
|
+
help_text=field.get("helpText"),
|
|
1002
|
+
default_value=field.get("defaultValue"),
|
|
1003
|
+
force=force,
|
|
1004
|
+
)
|
|
1005
|
+
print_success(f"Created setting file at {path}")
|
|
1006
|
+
else:
|
|
1007
|
+
path = append_manifest_list_item(
|
|
1008
|
+
root,
|
|
1009
|
+
config,
|
|
1010
|
+
list_key="settingsSchema",
|
|
1011
|
+
item=field,
|
|
1012
|
+
identity=lambda i: str(i.get("name") or "").strip().lower(),
|
|
1013
|
+
force=force,
|
|
1014
|
+
)
|
|
1015
|
+
print_success(f"Added setting '{field['name']}' to {path}")
|
|
1016
|
+
except FileExistsError as exc:
|
|
1017
|
+
raise ValueError(str(exc)) from None
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@app.command("add-pricing-plan")
|
|
1021
|
+
def add_pricing_plan(
|
|
1022
|
+
ctx: typer.Context,
|
|
1023
|
+
title: str | None = typer.Argument(None, help="Plan title (e.g. Free)."),
|
|
1024
|
+
pricing_type: str | None = typer.Option(None, "--type", help="FLAT or TIERED."),
|
|
1025
|
+
price_per_unit: float | None = typer.Option(None, "--price", help="FLAT pricePerUnit."),
|
|
1026
|
+
unit: str | None = typer.Option(None, "--unit", help="FLAT unit label."),
|
|
1027
|
+
description: str | None = typer.Option(None, "--description"),
|
|
1028
|
+
modular: bool = typer.Option(
|
|
1029
|
+
False,
|
|
1030
|
+
"--modular",
|
|
1031
|
+
help="Write src/app/pricing/<slug>.json instead of app.caraer.yaml.",
|
|
1032
|
+
),
|
|
1033
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing entry/file."),
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Add a pricing plan to app.caraer.yaml (wizard prompts when interactive)."""
|
|
1036
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1037
|
+
from caraer_cli.project.manifest_edit import append_manifest_list_item
|
|
1038
|
+
from caraer_cli.project.pricing_sync import pricing_identity, sanitize_pricing
|
|
1039
|
+
from caraer_cli.project.scaffold import scaffold_pricing_plan
|
|
1040
|
+
from caraer_cli.project.schema import load_workspace
|
|
1041
|
+
from caraer_cli.wizard.marketplace import prompt_pricing_plan
|
|
1042
|
+
|
|
1043
|
+
app_ctx: AppContext = ctx.obj
|
|
1044
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1045
|
+
config = load_workspace(root)
|
|
1046
|
+
plan = sanitize_pricing(
|
|
1047
|
+
prompt_pricing_plan(
|
|
1048
|
+
title=title,
|
|
1049
|
+
pricing_type=pricing_type,
|
|
1050
|
+
price_per_unit=price_per_unit,
|
|
1051
|
+
unit=unit,
|
|
1052
|
+
description=description,
|
|
1053
|
+
)
|
|
1054
|
+
)
|
|
1055
|
+
try:
|
|
1056
|
+
if modular:
|
|
1057
|
+
path = scaffold_pricing_plan(
|
|
1058
|
+
root,
|
|
1059
|
+
config,
|
|
1060
|
+
title=str(plan.get("title") or "Plan"),
|
|
1061
|
+
pricing_type=str(plan.get("pricingType") or "FLAT"),
|
|
1062
|
+
price_per_unit=plan.get("pricePerUnit", 0),
|
|
1063
|
+
unit=str(plan.get("unit") or "installations"),
|
|
1064
|
+
description=plan.get("description"),
|
|
1065
|
+
force=force,
|
|
1066
|
+
)
|
|
1067
|
+
print_success(f"Created pricing plan file at {path}")
|
|
1068
|
+
else:
|
|
1069
|
+
path = append_manifest_list_item(
|
|
1070
|
+
root,
|
|
1071
|
+
config,
|
|
1072
|
+
list_key="pricingPlans",
|
|
1073
|
+
item=plan,
|
|
1074
|
+
identity=pricing_identity,
|
|
1075
|
+
force=force,
|
|
1076
|
+
)
|
|
1077
|
+
print_success(f"Added pricing plan '{plan.get('title')}' to {path}")
|
|
1078
|
+
except FileExistsError as exc:
|
|
1079
|
+
raise ValueError(str(exc)) from None
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
@app.command("add-app-bar")
|
|
1083
|
+
def add_app_bar(
|
|
1084
|
+
ctx: typer.Context,
|
|
1085
|
+
label: str | None = typer.Argument(None, help="App bar label."),
|
|
1086
|
+
location: str | None = typer.Option(
|
|
1087
|
+
None,
|
|
1088
|
+
"--location",
|
|
1089
|
+
help="RECORD_PREVIEW|RECORD_OVERVIEW|RECORD_DETAIL|TOOL_BAR|TRAIT_BAR.",
|
|
1090
|
+
),
|
|
1091
|
+
function: str | None = typer.Option(
|
|
1092
|
+
None,
|
|
1093
|
+
"--function",
|
|
1094
|
+
"-f",
|
|
1095
|
+
help="Local function for action bars (SERVERLESS webhook).",
|
|
1096
|
+
autocompletion=complete_local_function,
|
|
1097
|
+
),
|
|
1098
|
+
iframe_url: str | None = typer.Option(
|
|
1099
|
+
None, "--iframe-url", help="Required for iframe locations."
|
|
1100
|
+
),
|
|
1101
|
+
modular: bool = typer.Option(
|
|
1102
|
+
False,
|
|
1103
|
+
"--modular",
|
|
1104
|
+
help="Write src/app/app-bars/<slug>.json instead of app.caraer.yaml.",
|
|
1105
|
+
),
|
|
1106
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing entry/file."),
|
|
1107
|
+
) -> None:
|
|
1108
|
+
"""Add an app bar to app.caraer.yaml (wizard prompts when interactive)."""
|
|
1109
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1110
|
+
from caraer_cli.project.app_bars_sync import app_bar_identity, sanitize_app_bar
|
|
1111
|
+
from caraer_cli.project.manifest_edit import append_manifest_list_item
|
|
1112
|
+
from caraer_cli.project.scaffold import scaffold_app_bar
|
|
1113
|
+
from caraer_cli.project.schema import load_workspace
|
|
1114
|
+
from caraer_cli.project.sync import list_local_function_names
|
|
1115
|
+
from caraer_cli.wizard.marketplace import prompt_app_bar
|
|
1116
|
+
|
|
1117
|
+
app_ctx: AppContext = ctx.obj
|
|
1118
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1119
|
+
config = load_workspace(root)
|
|
1120
|
+
bar = sanitize_app_bar(
|
|
1121
|
+
prompt_app_bar(
|
|
1122
|
+
label=label,
|
|
1123
|
+
location=location,
|
|
1124
|
+
function_name=normalize_function_name(function) if function else None,
|
|
1125
|
+
iframe_url=iframe_url,
|
|
1126
|
+
function_choices=list_local_function_names(root, config),
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
try:
|
|
1130
|
+
if modular:
|
|
1131
|
+
webhook = bar.get("webhook") if isinstance(bar.get("webhook"), dict) else {}
|
|
1132
|
+
sf = webhook.get("serverlessFunction") if isinstance(webhook, dict) else {}
|
|
1133
|
+
path = scaffold_app_bar(
|
|
1134
|
+
root,
|
|
1135
|
+
config,
|
|
1136
|
+
location=str(bar.get("location") or "RECORD_PREVIEW"),
|
|
1137
|
+
label=str(bar.get("label") or "Action"),
|
|
1138
|
+
iframe_url=bar.get("iframeUrl"),
|
|
1139
|
+
function_name=(sf or {}).get("name") if isinstance(sf, dict) else None,
|
|
1140
|
+
action_label=bar.get("actionLabel"),
|
|
1141
|
+
force=force,
|
|
1142
|
+
)
|
|
1143
|
+
print_success(f"Created app bar file at {path}")
|
|
1144
|
+
else:
|
|
1145
|
+
path = append_manifest_list_item(
|
|
1146
|
+
root,
|
|
1147
|
+
config,
|
|
1148
|
+
list_key="appBars",
|
|
1149
|
+
item=bar,
|
|
1150
|
+
identity=app_bar_identity,
|
|
1151
|
+
force=force,
|
|
1152
|
+
)
|
|
1153
|
+
print_success(f"Added app bar '{bar.get('label')}' to {path}")
|
|
1154
|
+
except (FileExistsError, ValueError) as exc:
|
|
1155
|
+
raise ValueError(str(exc)) from None
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
@app.command("add-lifecycle-hook")
|
|
1159
|
+
def add_lifecycle_hook(
|
|
1160
|
+
ctx: typer.Context,
|
|
1161
|
+
event: str | None = typer.Argument(
|
|
1162
|
+
None, help="install | uninstall | rotate | update"
|
|
1163
|
+
),
|
|
1164
|
+
function: str | None = typer.Option(
|
|
1165
|
+
None,
|
|
1166
|
+
"--function",
|
|
1167
|
+
"-f",
|
|
1168
|
+
help="Function name (default: on-<event>).",
|
|
1169
|
+
autocompletion=complete_local_function,
|
|
1170
|
+
),
|
|
1171
|
+
no_function: bool = typer.Option(
|
|
1172
|
+
False,
|
|
1173
|
+
"--no-function",
|
|
1174
|
+
help="Only write lifecycle/<event>.json (do not scaffold a function).",
|
|
1175
|
+
),
|
|
1176
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
1177
|
+
) -> None:
|
|
1178
|
+
"""Scaffold a lifecycle hook under src/app/lifecycle/ (+ optional function)."""
|
|
1179
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1180
|
+
from caraer_cli.project.scaffold import scaffold_lifecycle_hook
|
|
1181
|
+
from caraer_cli.project.schema import load_workspace
|
|
1182
|
+
from caraer_cli.wizard.marketplace import prompt_lifecycle_hook
|
|
1183
|
+
|
|
1184
|
+
app_ctx: AppContext = ctx.obj
|
|
1185
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1186
|
+
config = load_workspace(root)
|
|
1187
|
+
options = prompt_lifecycle_hook(
|
|
1188
|
+
event=event,
|
|
1189
|
+
function_name=normalize_function_name(function) if function else None,
|
|
1190
|
+
create_function=False if no_function else None,
|
|
1191
|
+
)
|
|
1192
|
+
try:
|
|
1193
|
+
result = scaffold_lifecycle_hook(
|
|
1194
|
+
root,
|
|
1195
|
+
config,
|
|
1196
|
+
event=str(options["event"]),
|
|
1197
|
+
function_name=options.get("function_name"),
|
|
1198
|
+
create_function=bool(options.get("create_function")),
|
|
1199
|
+
delivery_mode=str(options.get("deliveryMode") or "SERVERLESS"),
|
|
1200
|
+
url=options.get("url"),
|
|
1201
|
+
enabled=bool(options.get("enabled", True)),
|
|
1202
|
+
force=force,
|
|
1203
|
+
)
|
|
1204
|
+
except (FileExistsError, ValueError) as exc:
|
|
1205
|
+
raise ValueError(str(exc)) from None
|
|
1206
|
+
print_success(f"Created lifecycle hook at {result['lifecycleFile']}")
|
|
1207
|
+
if result.get("functionFolder"):
|
|
1208
|
+
print_success(f"Created function scaffold at {result['functionFolder']}")
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@app.command("status")
|
|
1212
|
+
def app_status(ctx: typer.Context) -> None:
|
|
1213
|
+
"""Show local↔remote function drift for the current app folder."""
|
|
1214
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1215
|
+
from caraer_cli.project.schema import load_workspace
|
|
1216
|
+
from caraer_cli.project.sync import status_summary
|
|
1217
|
+
|
|
1218
|
+
app_ctx: AppContext = ctx.obj
|
|
1219
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1220
|
+
config = load_workspace(root)
|
|
1221
|
+
print_data(status_summary(app_ctx.api_client(), root, config), app_ctx.output)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
@app.command("typegen")
|
|
1225
|
+
def typegen(
|
|
1226
|
+
ctx: typer.Context,
|
|
1227
|
+
file: str | None = typer.Option(
|
|
1228
|
+
None,
|
|
1229
|
+
"--file",
|
|
1230
|
+
"-f",
|
|
1231
|
+
help="App folder or app.caraer.yaml (defaults to selected local app / cwd).",
|
|
1232
|
+
),
|
|
1233
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing type files."),
|
|
1234
|
+
) -> None:
|
|
1235
|
+
"""Generate typed payload helpers (TypeScript or Python) under src/types/."""
|
|
1236
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1237
|
+
from caraer_cli.project.schema import load_workspace
|
|
1238
|
+
from caraer_cli.project.typegen import generate_types
|
|
1239
|
+
|
|
1240
|
+
app_ctx: AppContext = ctx.obj
|
|
1241
|
+
selected = file or app_ctx.profile.app_file
|
|
1242
|
+
try:
|
|
1243
|
+
root = resolve_app_root(app_file=selected)
|
|
1244
|
+
except FileNotFoundError:
|
|
1245
|
+
raise typer.BadParameter(
|
|
1246
|
+
"No app folder found. Pass --file, select a local app, or cd into an app directory."
|
|
1247
|
+
) from None
|
|
1248
|
+
|
|
1249
|
+
config = load_workspace(root)
|
|
1250
|
+
try:
|
|
1251
|
+
result = generate_types(root, config, force=force)
|
|
1252
|
+
except FileExistsError as exc:
|
|
1253
|
+
raise ValueError(str(exc)) from None
|
|
1254
|
+
print_success(
|
|
1255
|
+
f"Wrote {result['runtime']} types to {result['relative']} "
|
|
1256
|
+
f"(re-run with --force to overwrite)."
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@app.command("validate")
|
|
1261
|
+
def validate_app(
|
|
1262
|
+
ctx: typer.Context,
|
|
1263
|
+
file: str | None = typer.Option(
|
|
1264
|
+
None,
|
|
1265
|
+
"--file",
|
|
1266
|
+
"-f",
|
|
1267
|
+
help="App folder or app.caraer.yaml (defaults to selected local app / cwd).",
|
|
1268
|
+
),
|
|
1269
|
+
strict: bool = typer.Option(
|
|
1270
|
+
False,
|
|
1271
|
+
"--strict",
|
|
1272
|
+
help="Treat warnings as failures (non-zero exit).",
|
|
1273
|
+
),
|
|
1274
|
+
) -> None:
|
|
1275
|
+
"""Validate the local app (manifest, functions, marketplace modules, lifecycle)."""
|
|
1276
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1277
|
+
from caraer_cli.formatters.output import print_error
|
|
1278
|
+
from caraer_cli.project.validate_app import validate_local_app
|
|
1279
|
+
|
|
1280
|
+
app_ctx: AppContext = ctx.obj
|
|
1281
|
+
selected = file or app_ctx.profile.app_file
|
|
1282
|
+
try:
|
|
1283
|
+
root = resolve_app_root(app_file=selected)
|
|
1284
|
+
except FileNotFoundError:
|
|
1285
|
+
raise typer.BadParameter(
|
|
1286
|
+
"No app folder found. Pass --file, select a local app, or cd into an app directory."
|
|
1287
|
+
) from None
|
|
1288
|
+
|
|
1289
|
+
report = validate_local_app(root, strict=strict)
|
|
1290
|
+
payload = report.to_dict()
|
|
1291
|
+
if app_ctx.output == "table":
|
|
1292
|
+
if report.issues:
|
|
1293
|
+
print_data(
|
|
1294
|
+
[
|
|
1295
|
+
{
|
|
1296
|
+
"severity": issue.severity,
|
|
1297
|
+
"path": issue.path,
|
|
1298
|
+
"message": issue.message,
|
|
1299
|
+
}
|
|
1300
|
+
for issue in report.issues
|
|
1301
|
+
],
|
|
1302
|
+
app_ctx.output,
|
|
1303
|
+
)
|
|
1304
|
+
summary = (
|
|
1305
|
+
f"Validated {report.root} — "
|
|
1306
|
+
f"{payload['functions']} function(s), {payload['settings']} setting(s), "
|
|
1307
|
+
f"{payload['pricingPlans']} pricing plan(s), {payload['appBars']} app bar(s), "
|
|
1308
|
+
f"{payload['lifecycleHooks']} lifecycle hook(s), "
|
|
1309
|
+
f"{payload['errors']} error(s), {payload['warnings']} warning(s)."
|
|
1310
|
+
)
|
|
1311
|
+
if report.ok:
|
|
1312
|
+
print_success(summary)
|
|
1313
|
+
else:
|
|
1314
|
+
print_error(summary)
|
|
1315
|
+
else:
|
|
1316
|
+
print_data(payload, app_ctx.output)
|
|
1317
|
+
|
|
1318
|
+
if not report.ok:
|
|
1319
|
+
raise typer.Exit(code=1)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _prompt_migrate_runtime(*, default: str = "nodejs22", reason: str | None = None) -> str:
|
|
1323
|
+
from caraer_cli.formatters.output import print_error
|
|
1324
|
+
|
|
1325
|
+
if reason:
|
|
1326
|
+
print_error(reason.rstrip(".") + ".")
|
|
1327
|
+
|
|
1328
|
+
resolved_default = default if default in {"nodejs22", "python312"} else "nodejs22"
|
|
1329
|
+
try:
|
|
1330
|
+
from questionary import Choice
|
|
1331
|
+
|
|
1332
|
+
from caraer_cli.wizard.prompts import WizardCancelled, ask_select
|
|
1333
|
+
|
|
1334
|
+
try:
|
|
1335
|
+
return ask_select(
|
|
1336
|
+
"Choose the App runtime for V2",
|
|
1337
|
+
[
|
|
1338
|
+
Choice(title="nodejs22 (Node.js 22)", value="nodejs22"),
|
|
1339
|
+
Choice(title="python312 (Python 3.12)", value="python312"),
|
|
1340
|
+
],
|
|
1341
|
+
default=resolved_default,
|
|
1342
|
+
)
|
|
1343
|
+
except WizardCancelled:
|
|
1344
|
+
raise typer.Exit(code=1) from None
|
|
1345
|
+
except ModuleNotFoundError:
|
|
1346
|
+
# questionary is a declared dependency; fall back if the install is incomplete.
|
|
1347
|
+
value = typer.prompt(
|
|
1348
|
+
"Choose the App runtime for V2 [nodejs22/python312]",
|
|
1349
|
+
default=resolved_default,
|
|
1350
|
+
).strip().lower()
|
|
1351
|
+
if value not in {"nodejs22", "python312"}:
|
|
1352
|
+
raise typer.BadParameter("runtime must be nodejs22 or python312") from None
|
|
1353
|
+
return value
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
@app.command("migrate-v2")
|
|
1357
|
+
def migrate_v2(
|
|
1358
|
+
ctx: typer.Context,
|
|
1359
|
+
app_uuid: str | None = typer.Option(
|
|
1360
|
+
None,
|
|
1361
|
+
"--app",
|
|
1362
|
+
"--app-uuid",
|
|
1363
|
+
help="Remote app UUID (defaults to selected / linked app).",
|
|
1364
|
+
),
|
|
1365
|
+
runtime: str | None = typer.Option(
|
|
1366
|
+
None,
|
|
1367
|
+
"--runtime",
|
|
1368
|
+
help="nodejs22 or python312 (prompted interactively when missing/ambiguous).",
|
|
1369
|
+
autocompletion=complete_runtime,
|
|
1370
|
+
),
|
|
1371
|
+
) -> None:
|
|
1372
|
+
"""Migrate a V1 app to the V2 shared-container runtime (one-way).
|
|
1373
|
+
|
|
1374
|
+
Keeps webhook delivery on legacy per-function URLs until the new runtime is
|
|
1375
|
+
READY, then cut over. Updates local caraer.json to platformVersion 2026.2.
|
|
1376
|
+
"""
|
|
1377
|
+
from caraer_cli.api import apps as apps_api
|
|
1378
|
+
from caraer_cli.app_sync import _poll_v2_runtime, resolve_app_root
|
|
1379
|
+
from caraer_cli.errors import ApiError
|
|
1380
|
+
from caraer_cli.project.paths import workspace_file
|
|
1381
|
+
from caraer_cli.project.schema import PLATFORM_VERSION, load_workspace, save_project_config
|
|
1382
|
+
|
|
1383
|
+
app_ctx: AppContext = ctx.obj
|
|
1384
|
+
if runtime is not None and runtime not in {"nodejs22", "python312"}:
|
|
1385
|
+
raise typer.BadParameter("--runtime must be nodejs22 or python312.")
|
|
1386
|
+
|
|
1387
|
+
resolved = resolve_app_uuid(app_ctx, app_uuid)
|
|
1388
|
+
client = app_ctx.api_client()
|
|
1389
|
+
|
|
1390
|
+
local_default = "nodejs22"
|
|
1391
|
+
try:
|
|
1392
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1393
|
+
local_default = load_workspace(root).resolved_runtime("nodejs22")
|
|
1394
|
+
except (FileNotFoundError, ValueError):
|
|
1395
|
+
pass
|
|
1396
|
+
|
|
1397
|
+
try:
|
|
1398
|
+
response = apps_api.migrate_app_to_v2(client, resolved, runtime=runtime)
|
|
1399
|
+
except ApiError as exc:
|
|
1400
|
+
message = str(exc.message or exc)
|
|
1401
|
+
needs_runtime = exc.status == 400 and (
|
|
1402
|
+
"runtime" in message.lower()
|
|
1403
|
+
and (
|
|
1404
|
+
"pass runtime=" in message.lower()
|
|
1405
|
+
or "mixed runtimes" in message.lower()
|
|
1406
|
+
or "cannot infer" in message.lower()
|
|
1407
|
+
)
|
|
1408
|
+
)
|
|
1409
|
+
if runtime is not None or not needs_runtime:
|
|
1410
|
+
raise
|
|
1411
|
+
runtime = _prompt_migrate_runtime(default=local_default, reason=message)
|
|
1412
|
+
response = apps_api.migrate_app_to_v2(client, resolved, runtime=runtime)
|
|
1413
|
+
|
|
1414
|
+
data = response.get("data") if isinstance(response.get("data"), dict) else {}
|
|
1415
|
+
migration = data.get("migration") if isinstance(data.get("migration"), dict) else {}
|
|
1416
|
+
app_payload = data.get("app") if isinstance(data.get("app"), dict) else {}
|
|
1417
|
+
linked_uuid = str(app_payload.get("uuid") or resolved)
|
|
1418
|
+
|
|
1419
|
+
already = bool(migration.get("alreadyMigratingOrMigrated"))
|
|
1420
|
+
print_success(
|
|
1421
|
+
("Migration already in progress for " if already else "Migration started for ")
|
|
1422
|
+
+ f"{linked_uuid} "
|
|
1423
|
+
f"(from v{migration.get('fromVersion')} → v{migration.get('toVersion')}, "
|
|
1424
|
+
f"runtime={migration.get('runtime')}, functions={migration.get('functionCount')}, "
|
|
1425
|
+
f"status={migration.get('runtimeStatus')})."
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
# Poll using a temporary V2 workspace config for the public app GET.
|
|
1429
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
1430
|
+
|
|
1431
|
+
poll_config = ProjectConfig(
|
|
1432
|
+
platformVersion=PLATFORM_VERSION,
|
|
1433
|
+
name=str(app_payload.get("name") or "app"),
|
|
1434
|
+
appUuid=linked_uuid,
|
|
1435
|
+
runtime=str(migration.get("runtime") or runtime or "nodejs22"),
|
|
1436
|
+
)
|
|
1437
|
+
initial_status = str(migration.get("runtimeStatus") or "").upper()
|
|
1438
|
+
if initial_status == "READY":
|
|
1439
|
+
runtime_status = {
|
|
1440
|
+
"platformVersion": app_payload.get("platformVersion"),
|
|
1441
|
+
"runtime": app_payload.get("runtime") or migration.get("runtime"),
|
|
1442
|
+
"runtimeStatus": "READY",
|
|
1443
|
+
"runtimeBaseUrl": app_payload.get("runtimeBaseUrl"),
|
|
1444
|
+
"runtimeRevision": app_payload.get("runtimeRevision"),
|
|
1445
|
+
"runtimeError": app_payload.get("runtimeError"),
|
|
1446
|
+
}
|
|
1447
|
+
else:
|
|
1448
|
+
runtime_status = _poll_v2_runtime(client, poll_config, progress=True)
|
|
1449
|
+
status = str(runtime_status.get("runtimeStatus") or "").upper()
|
|
1450
|
+
if status == "FAILED":
|
|
1451
|
+
raise ValueError(
|
|
1452
|
+
"V2 runtime deploy failed: "
|
|
1453
|
+
+ str(runtime_status.get("runtimeError") or "unknown error")
|
|
1454
|
+
)
|
|
1455
|
+
if status != "READY":
|
|
1456
|
+
raise ValueError(
|
|
1457
|
+
f"Timed out waiting for runtime READY (last status={status or 'unknown'}). "
|
|
1458
|
+
"Ensure the backend AppRuntimeWorker is running, then retry "
|
|
1459
|
+
"'caraer apps migrate-v2'."
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
# Bump local workspace when we are inside / have selected an app folder.
|
|
1463
|
+
try:
|
|
1464
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1465
|
+
config = load_workspace(root)
|
|
1466
|
+
if config.appUuid and config.appUuid not in {linked_uuid, resolved}:
|
|
1467
|
+
print_success(
|
|
1468
|
+
f"Remote app {linked_uuid} is READY; local folder is linked to "
|
|
1469
|
+
f"{config.appUuid} — not updating caraer.json."
|
|
1470
|
+
)
|
|
1471
|
+
else:
|
|
1472
|
+
config.platformVersion = PLATFORM_VERSION
|
|
1473
|
+
config.appUuid = linked_uuid
|
|
1474
|
+
config.runtime = str(
|
|
1475
|
+
runtime_status.get("runtime") or migration.get("runtime") or config.runtime or "nodejs22"
|
|
1476
|
+
)
|
|
1477
|
+
save_project_config(workspace_file(root), config)
|
|
1478
|
+
print_success(f"Updated {workspace_file(root)} to platformVersion {PLATFORM_VERSION}.")
|
|
1479
|
+
except (FileNotFoundError, ValueError):
|
|
1480
|
+
print_success("Remote migration READY. Link/select a local app folder to sync caraer.json.")
|
|
1481
|
+
|
|
1482
|
+
print_data(
|
|
1483
|
+
{
|
|
1484
|
+
"appUuid": linked_uuid,
|
|
1485
|
+
"migration": migration,
|
|
1486
|
+
"runtime": runtime_status,
|
|
1487
|
+
},
|
|
1488
|
+
app_ctx.output,
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@app.command("deploy")
|
|
1493
|
+
def deploy_app(
|
|
1494
|
+
ctx: typer.Context,
|
|
1495
|
+
build_uuid: str | None = typer.Argument(None, help="Build UUID (defaults to last build)."),
|
|
1496
|
+
target: str = typer.Option("production", "--target", help="production|sandbox"),
|
|
1497
|
+
wait: bool = typer.Option(
|
|
1498
|
+
True,
|
|
1499
|
+
"--wait/--no-wait",
|
|
1500
|
+
help="For V2 apps, poll runtimeStatus until READY/FAILED (default: wait).",
|
|
1501
|
+
),
|
|
1502
|
+
) -> None:
|
|
1503
|
+
"""Deploy a previously created build for this app."""
|
|
1504
|
+
from caraer_cli.api import projects as projects_api
|
|
1505
|
+
from caraer_cli.app_sync import (
|
|
1506
|
+
_poll_v2_runtime,
|
|
1507
|
+
_print_deploy_results,
|
|
1508
|
+
_refresh_deploy,
|
|
1509
|
+
require_project_uuid,
|
|
1510
|
+
resolve_app_root,
|
|
1511
|
+
)
|
|
1512
|
+
from caraer_cli.formatters.output import print_warning
|
|
1513
|
+
from caraer_cli.project.schema import load_workspace
|
|
1514
|
+
from caraer_cli.project.state import load_state, save_state
|
|
1515
|
+
|
|
1516
|
+
app_ctx: AppContext = ctx.obj
|
|
1517
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1518
|
+
config = load_workspace(root)
|
|
1519
|
+
client = app_ctx.api_client()
|
|
1520
|
+
project_uuid = require_project_uuid(client, root, config)
|
|
1521
|
+
state = load_state(root)
|
|
1522
|
+
resolved = build_uuid or state.get("lastBuildUuid")
|
|
1523
|
+
if not resolved:
|
|
1524
|
+
raise ValueError("No build UUID provided and no lastBuildUuid in local state.")
|
|
1525
|
+
if target == "sandbox":
|
|
1526
|
+
print_warning(
|
|
1527
|
+
"Sandbox deploys isolate Neo4j data via X-Caraer-Sandbox-Uuid, "
|
|
1528
|
+
"but the Cloud Function runtime is shared with production."
|
|
1529
|
+
)
|
|
1530
|
+
response = projects_api.deploy_build(
|
|
1531
|
+
client, project_uuid, str(resolved), target=target, prune=False
|
|
1532
|
+
)
|
|
1533
|
+
data = response.get("data") or {}
|
|
1534
|
+
state["lastDeployUuid"] = data.get("uuid")
|
|
1535
|
+
save_state(root, state)
|
|
1536
|
+
print_success(f"Deployed build {resolved}")
|
|
1537
|
+
if isinstance(data, dict):
|
|
1538
|
+
_print_deploy_results(data)
|
|
1539
|
+
if config.is_app_platform_v2() and wait:
|
|
1540
|
+
runtime = _poll_v2_runtime(client, config, progress=True)
|
|
1541
|
+
refreshed = _refresh_deploy(
|
|
1542
|
+
client, project_uuid, data.get("uuid") if isinstance(data, dict) else None
|
|
1543
|
+
)
|
|
1544
|
+
if refreshed:
|
|
1545
|
+
data = refreshed
|
|
1546
|
+
_print_deploy_results(refreshed)
|
|
1547
|
+
if isinstance(data, dict):
|
|
1548
|
+
data = {**data, "runtime": runtime}
|
|
1549
|
+
elif config.is_app_platform_v2() and not wait:
|
|
1550
|
+
print_success(
|
|
1551
|
+
"Skipping runtime wait. Check later with 'caraer apps status' "
|
|
1552
|
+
"(runtimeStatus) or re-run with --wait."
|
|
1553
|
+
)
|
|
1554
|
+
print_data(data, app_ctx.output)
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
builds_app = typer.Typer(help="Developer-project builds.", no_args_is_help=True)
|
|
1558
|
+
app.add_typer(builds_app, name="builds")
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
@builds_app.command("list")
|
|
1562
|
+
def list_builds(ctx: typer.Context) -> None:
|
|
1563
|
+
"""List builds for the selected app's developer project (newest first)."""
|
|
1564
|
+
from caraer_cli.api import projects as projects_api
|
|
1565
|
+
from caraer_cli.app_sync import require_project_uuid, resolve_app_root
|
|
1566
|
+
from caraer_cli.project.schema import load_workspace
|
|
1567
|
+
|
|
1568
|
+
app_ctx: AppContext = ctx.obj
|
|
1569
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1570
|
+
client = app_ctx.api_client()
|
|
1571
|
+
project_uuid = require_project_uuid(client, root, load_workspace(root))
|
|
1572
|
+
project = projects_api.get_project(client, project_uuid).get("data") or {}
|
|
1573
|
+
active_build = str(project.get("activeBuildUuid") or "")
|
|
1574
|
+
rows = projects_api.list_builds(client, project_uuid).get("data") or []
|
|
1575
|
+
if isinstance(rows, list):
|
|
1576
|
+
marked = []
|
|
1577
|
+
for item in rows:
|
|
1578
|
+
if not isinstance(item, dict):
|
|
1579
|
+
continue
|
|
1580
|
+
row = dict(item)
|
|
1581
|
+
row["live"] = bool(active_build and row.get("uuid") == active_build)
|
|
1582
|
+
# Prefer compact table columns over raw archive/manifest blobs.
|
|
1583
|
+
row.pop("manifestJson", None)
|
|
1584
|
+
row.pop("artifactGcsPath", None)
|
|
1585
|
+
marked.append(row)
|
|
1586
|
+
rows = marked
|
|
1587
|
+
print_data(rows, app_ctx.output)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
@builds_app.command("get")
|
|
1591
|
+
def get_build(
|
|
1592
|
+
ctx: typer.Context,
|
|
1593
|
+
build_uuid: str = typer.Argument(..., help="Build UUID."),
|
|
1594
|
+
) -> None:
|
|
1595
|
+
"""Fetch a single developer-project build by UUID."""
|
|
1596
|
+
from caraer_cli.api import projects as projects_api
|
|
1597
|
+
from caraer_cli.app_sync import require_project_uuid, resolve_app_root
|
|
1598
|
+
from caraer_cli.project.schema import load_workspace
|
|
1599
|
+
|
|
1600
|
+
app_ctx: AppContext = ctx.obj
|
|
1601
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1602
|
+
client = app_ctx.api_client()
|
|
1603
|
+
project_uuid = require_project_uuid(client, root, load_workspace(root))
|
|
1604
|
+
response = projects_api.get_build(client, project_uuid, build_uuid)
|
|
1605
|
+
data = response.get("data") or {}
|
|
1606
|
+
if isinstance(data, dict):
|
|
1607
|
+
data = dict(data)
|
|
1608
|
+
data.pop("manifestJson", None)
|
|
1609
|
+
data.pop("artifactGcsPath", None)
|
|
1610
|
+
print_data(data, app_ctx.output)
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
@app.command("rollback")
|
|
1614
|
+
def rollback_app(
|
|
1615
|
+
ctx: typer.Context,
|
|
1616
|
+
version: str | None = typer.Option(
|
|
1617
|
+
None,
|
|
1618
|
+
"--version",
|
|
1619
|
+
help="Semver of a prior READY build to redeploy.",
|
|
1620
|
+
),
|
|
1621
|
+
build: str | None = typer.Option(
|
|
1622
|
+
None,
|
|
1623
|
+
"--build",
|
|
1624
|
+
help="Build UUID to redeploy (overrides --version).",
|
|
1625
|
+
),
|
|
1626
|
+
target: str = typer.Option("production", "--target", help="production|sandbox"),
|
|
1627
|
+
wait: bool = typer.Option(
|
|
1628
|
+
True,
|
|
1629
|
+
"--wait/--no-wait",
|
|
1630
|
+
help="For V2 apps, poll runtimeStatus until READY/FAILED (default: wait).",
|
|
1631
|
+
),
|
|
1632
|
+
) -> None:
|
|
1633
|
+
"""Redeploy a prior READY build (rollback). Defaults to the build before active."""
|
|
1634
|
+
from caraer_cli.api import projects as projects_api
|
|
1635
|
+
from caraer_cli.app_sync import _poll_v2_runtime, require_project_uuid, resolve_app_root
|
|
1636
|
+
from caraer_cli.project.schema import load_workspace
|
|
1637
|
+
from caraer_cli.project.state import load_state, save_state
|
|
1638
|
+
|
|
1639
|
+
app_ctx: AppContext = ctx.obj
|
|
1640
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1641
|
+
config = load_workspace(root)
|
|
1642
|
+
client = app_ctx.api_client()
|
|
1643
|
+
project_uuid = require_project_uuid(client, root, config)
|
|
1644
|
+
project = projects_api.get_project(client, project_uuid).get("data") or {}
|
|
1645
|
+
active_build = str(project.get("activeBuildUuid") or "")
|
|
1646
|
+
builds = projects_api.list_builds(client, project_uuid).get("data") or []
|
|
1647
|
+
if not isinstance(builds, list):
|
|
1648
|
+
builds = []
|
|
1649
|
+
|
|
1650
|
+
resolved: str | None = build
|
|
1651
|
+
if not resolved and version:
|
|
1652
|
+
for item in builds:
|
|
1653
|
+
if (
|
|
1654
|
+
isinstance(item, dict)
|
|
1655
|
+
and str(item.get("version") or "") == version
|
|
1656
|
+
and str(item.get("status") or "").upper() == "READY"
|
|
1657
|
+
):
|
|
1658
|
+
resolved = str(item.get("uuid") or "")
|
|
1659
|
+
break
|
|
1660
|
+
if not resolved:
|
|
1661
|
+
raise ValueError(f"No READY build found with version '{version}'.")
|
|
1662
|
+
if not resolved:
|
|
1663
|
+
# Default: first READY build that is not the currently active one
|
|
1664
|
+
# (builds are newest-first).
|
|
1665
|
+
for item in builds:
|
|
1666
|
+
if not isinstance(item, dict):
|
|
1667
|
+
continue
|
|
1668
|
+
uuid = str(item.get("uuid") or "")
|
|
1669
|
+
if not uuid or uuid == active_build:
|
|
1670
|
+
continue
|
|
1671
|
+
if str(item.get("status") or "").upper() != "READY":
|
|
1672
|
+
continue
|
|
1673
|
+
resolved = uuid
|
|
1674
|
+
break
|
|
1675
|
+
if not resolved:
|
|
1676
|
+
raise ValueError(
|
|
1677
|
+
"No prior READY build to roll back to. "
|
|
1678
|
+
"Pass --build <uuid> or --version X.Y.Z."
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
print_success(f"Rolling back to build {resolved}…")
|
|
1682
|
+
response = projects_api.deploy_build(client, project_uuid, resolved, target=target)
|
|
1683
|
+
data = response.get("data") or {}
|
|
1684
|
+
state = load_state(root)
|
|
1685
|
+
state["lastDeployUuid"] = data.get("uuid") if isinstance(data, dict) else None
|
|
1686
|
+
state["lastBuildUuid"] = resolved
|
|
1687
|
+
save_state(root, state)
|
|
1688
|
+
if config.is_app_platform_v2() and wait:
|
|
1689
|
+
runtime = _poll_v2_runtime(client, config, progress=True)
|
|
1690
|
+
if isinstance(data, dict):
|
|
1691
|
+
data = {**data, "runtime": runtime, "rolledBackToBuild": resolved}
|
|
1692
|
+
print_success(f"Rollback deploy started for build {resolved}")
|
|
1693
|
+
print_data(data, app_ctx.output)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
@app.command("test")
|
|
1697
|
+
def test_function_cmd(
|
|
1698
|
+
ctx: typer.Context,
|
|
1699
|
+
function: str | None = typer.Argument(
|
|
1700
|
+
None,
|
|
1701
|
+
help="Local function name (defaults to the only local function, or prompts).",
|
|
1702
|
+
autocompletion=complete_local_function,
|
|
1703
|
+
),
|
|
1704
|
+
record_uuid: str = typer.Option(..., "--record", help="Record UUID for the sample payload."),
|
|
1705
|
+
event_type: str = typer.Option(
|
|
1706
|
+
"updated",
|
|
1707
|
+
"--event",
|
|
1708
|
+
help="Event type (created|updated|deleted).",
|
|
1709
|
+
),
|
|
1710
|
+
sample_only: bool = typer.Option(
|
|
1711
|
+
False,
|
|
1712
|
+
"--sample-only",
|
|
1713
|
+
help="Only fetch a sample payload; do not invoke the remote function.",
|
|
1714
|
+
),
|
|
1715
|
+
force_provision: bool = typer.Option(
|
|
1716
|
+
False,
|
|
1717
|
+
"--force-provision",
|
|
1718
|
+
help="V1 only: force Cloud Function provision before invoke.",
|
|
1719
|
+
),
|
|
1720
|
+
) -> None:
|
|
1721
|
+
"""Remote-invoke a function with a sample webhook payload (or print the sample)."""
|
|
1722
|
+
from caraer_cli.api import functions as functions_api
|
|
1723
|
+
from caraer_cli.api import projects as projects_api
|
|
1724
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1725
|
+
from caraer_cli.project.schema import load_workspace
|
|
1726
|
+
from caraer_cli.project.state import load_state
|
|
1727
|
+
from caraer_cli.project.sync import resolve_local_function_name
|
|
1728
|
+
|
|
1729
|
+
app_ctx: AppContext = ctx.obj
|
|
1730
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1731
|
+
config = load_workspace(root)
|
|
1732
|
+
if not config.appUuid:
|
|
1733
|
+
raise ValueError("App has no remote UUID. Run 'caraer apps select' or 'caraer apps push'.")
|
|
1734
|
+
function_name = resolve_local_function_name(root, config, function)
|
|
1735
|
+
state = load_state(root)
|
|
1736
|
+
fn_meta = (state.get("functions") or {}).get(function_name) or {}
|
|
1737
|
+
function_uuid = fn_meta.get("uuid")
|
|
1738
|
+
client = app_ctx.api_client()
|
|
1739
|
+
if not function_uuid:
|
|
1740
|
+
remote = functions_api.list_functions(client, config.appUuid, page=1, limit=200)
|
|
1741
|
+
for item in remote.get("data") or []:
|
|
1742
|
+
if isinstance(item, dict) and item.get("name") == function_name and item.get("uuid"):
|
|
1743
|
+
function_uuid = item["uuid"]
|
|
1744
|
+
break
|
|
1745
|
+
if not function_uuid:
|
|
1746
|
+
raise ValueError(
|
|
1747
|
+
f"Function '{function_name}' is not tracked locally. Run 'caraer apps push' first."
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
if sample_only:
|
|
1751
|
+
response = projects_api.sample_payload(
|
|
1752
|
+
client,
|
|
1753
|
+
config.appUuid,
|
|
1754
|
+
record_uuid=record_uuid,
|
|
1755
|
+
event_type=event_type,
|
|
1756
|
+
)
|
|
1757
|
+
print_success(f"Sample payload for '{function_name}'")
|
|
1758
|
+
print_data(response.get("data"), app_ctx.output)
|
|
1759
|
+
return
|
|
1760
|
+
|
|
1761
|
+
print_success(f"Testing remote function '{function_name}'…")
|
|
1762
|
+
response = functions_api.test_function(
|
|
1763
|
+
client,
|
|
1764
|
+
config.appUuid,
|
|
1765
|
+
str(function_uuid),
|
|
1766
|
+
record_uuid,
|
|
1767
|
+
event_type,
|
|
1768
|
+
force_provision=force_provision,
|
|
1769
|
+
)
|
|
1770
|
+
print_data(response.get("data"), app_ctx.output)
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
@app.command("deploys")
|
|
1774
|
+
def list_deploys(ctx: typer.Context) -> None:
|
|
1775
|
+
"""List deploys for the selected app's developer project (newest first)."""
|
|
1776
|
+
from caraer_cli.api import projects as projects_api
|
|
1777
|
+
from caraer_cli.app_sync import require_project_uuid, resolve_app_root
|
|
1778
|
+
from caraer_cli.project.schema import load_workspace
|
|
1779
|
+
|
|
1780
|
+
app_ctx: AppContext = ctx.obj
|
|
1781
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1782
|
+
project_uuid = require_project_uuid(
|
|
1783
|
+
app_ctx.api_client(), root, load_workspace(root)
|
|
1784
|
+
)
|
|
1785
|
+
rows = projects_api.list_deploys(app_ctx.api_client(), project_uuid).get("data") or []
|
|
1786
|
+
if isinstance(rows, list):
|
|
1787
|
+
cleaned = []
|
|
1788
|
+
for item in rows:
|
|
1789
|
+
if not isinstance(item, dict):
|
|
1790
|
+
continue
|
|
1791
|
+
row = dict(item)
|
|
1792
|
+
row.pop("resultsJson", None)
|
|
1793
|
+
cleaned.append(row)
|
|
1794
|
+
rows = cleaned
|
|
1795
|
+
print_data(rows, app_ctx.output)
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
@app.command("version")
|
|
1799
|
+
def app_version(ctx: typer.Context) -> None:
|
|
1800
|
+
"""Show the live (active) build version and recent releases."""
|
|
1801
|
+
from caraer_cli.api import projects as projects_api
|
|
1802
|
+
from caraer_cli.app_sync import require_project_uuid, resolve_app_root
|
|
1803
|
+
from caraer_cli.project.schema import load_workspace
|
|
1804
|
+
|
|
1805
|
+
app_ctx: AppContext = ctx.obj
|
|
1806
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1807
|
+
client = app_ctx.api_client()
|
|
1808
|
+
config = load_workspace(root)
|
|
1809
|
+
project_uuid = require_project_uuid(client, root, config)
|
|
1810
|
+
project = projects_api.get_project(client, project_uuid).get("data") or {}
|
|
1811
|
+
builds = projects_api.list_builds(client, project_uuid).get("data") or []
|
|
1812
|
+
active_build_uuid = str(project.get("activeBuildUuid") or "")
|
|
1813
|
+
active_version = project.get("activeVersion")
|
|
1814
|
+
active_build: dict[str, Any] | None = None
|
|
1815
|
+
recent: list[dict[str, Any]] = []
|
|
1816
|
+
if isinstance(builds, list):
|
|
1817
|
+
for item in builds:
|
|
1818
|
+
if not isinstance(item, dict):
|
|
1819
|
+
continue
|
|
1820
|
+
if active_build_uuid and item.get("uuid") == active_build_uuid:
|
|
1821
|
+
active_build = item
|
|
1822
|
+
if not active_version:
|
|
1823
|
+
active_version = item.get("version")
|
|
1824
|
+
recent.append(
|
|
1825
|
+
{
|
|
1826
|
+
"version": item.get("version"),
|
|
1827
|
+
"uuid": item.get("uuid"),
|
|
1828
|
+
"status": item.get("status"),
|
|
1829
|
+
"live": bool(active_build_uuid and item.get("uuid") == active_build_uuid),
|
|
1830
|
+
"releaseNotes": item.get("releaseNotes"),
|
|
1831
|
+
"createdAt": item.get("createdAt"),
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
recent = recent[:10]
|
|
1835
|
+
|
|
1836
|
+
payload = {
|
|
1837
|
+
"appUuid": config.appUuid,
|
|
1838
|
+
"projectUuid": project_uuid,
|
|
1839
|
+
"liveVersion": active_version,
|
|
1840
|
+
"activeBuildUuid": active_build_uuid or None,
|
|
1841
|
+
"activeDeployUuid": project.get("activeDeployUuid"),
|
|
1842
|
+
"activeBuild": (
|
|
1843
|
+
{
|
|
1844
|
+
"uuid": active_build.get("uuid"),
|
|
1845
|
+
"version": active_build.get("version"),
|
|
1846
|
+
"status": active_build.get("status"),
|
|
1847
|
+
"releaseNotes": active_build.get("releaseNotes"),
|
|
1848
|
+
"createdAt": active_build.get("createdAt"),
|
|
1849
|
+
}
|
|
1850
|
+
if active_build
|
|
1851
|
+
else None
|
|
1852
|
+
),
|
|
1853
|
+
"recentBuilds": recent,
|
|
1854
|
+
}
|
|
1855
|
+
if active_version:
|
|
1856
|
+
print_success(f"Live version: v{active_version}")
|
|
1857
|
+
else:
|
|
1858
|
+
print_success("No live version yet (deploy a build first).")
|
|
1859
|
+
print_data(payload, app_ctx.output)
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
@app.command("logs")
|
|
1863
|
+
def app_logs(
|
|
1864
|
+
ctx: typer.Context,
|
|
1865
|
+
function: str | None = typer.Option(
|
|
1866
|
+
None,
|
|
1867
|
+
"--function",
|
|
1868
|
+
"-f",
|
|
1869
|
+
help="Local function name (defaults to the only local function, or prompts).",
|
|
1870
|
+
autocompletion=complete_local_function,
|
|
1871
|
+
),
|
|
1872
|
+
all_runtime: bool = typer.Option(
|
|
1873
|
+
False,
|
|
1874
|
+
"--all",
|
|
1875
|
+
help="Fetch app-level V2 container logs (no function filter).",
|
|
1876
|
+
),
|
|
1877
|
+
since: str = typer.Option("1h", "--since", help="Lookback window, e.g. 15m, 1h, 24h."),
|
|
1878
|
+
follow: bool = typer.Option(
|
|
1879
|
+
False,
|
|
1880
|
+
"--follow",
|
|
1881
|
+
help="Follow new log lines (SSE for --all; poll otherwise).",
|
|
1882
|
+
),
|
|
1883
|
+
limit: int = typer.Option(100, "--limit", help="Maximum log lines to return."),
|
|
1884
|
+
) -> None:
|
|
1885
|
+
"""Fetch remote logs for a local function (or the whole V2 runtime with --all)."""
|
|
1886
|
+
import time
|
|
1887
|
+
|
|
1888
|
+
from caraer_cli.api import functions as functions_api
|
|
1889
|
+
from caraer_cli.api import projects as projects_api
|
|
1890
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1891
|
+
from caraer_cli.errors import ApiError, NotFoundError
|
|
1892
|
+
from caraer_cli.project.schema import load_workspace
|
|
1893
|
+
from caraer_cli.project.state import load_state
|
|
1894
|
+
from caraer_cli.project.sync import resolve_local_function_name
|
|
1895
|
+
|
|
1896
|
+
app_ctx: AppContext = ctx.obj
|
|
1897
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
1898
|
+
config = load_workspace(root)
|
|
1899
|
+
if not config.appUuid:
|
|
1900
|
+
raise ValueError("App has no remote UUID. Run 'caraer apps select' or 'caraer apps push'.")
|
|
1901
|
+
|
|
1902
|
+
client = app_ctx.api_client()
|
|
1903
|
+
function_uuid: str | None = None
|
|
1904
|
+
function_name: str | None = None
|
|
1905
|
+
if not all_runtime:
|
|
1906
|
+
function_name = resolve_local_function_name(root, config, function)
|
|
1907
|
+
state = load_state(root)
|
|
1908
|
+
fn_meta = (state.get("functions") or {}).get(function_name) or {}
|
|
1909
|
+
function_uuid = fn_meta.get("uuid")
|
|
1910
|
+
if not function_uuid:
|
|
1911
|
+
remote = functions_api.list_functions(client, config.appUuid, page=1, limit=200)
|
|
1912
|
+
for item in remote.get("data") or []:
|
|
1913
|
+
if isinstance(item, dict) and item.get("name") == function_name and item.get("uuid"):
|
|
1914
|
+
function_uuid = item["uuid"]
|
|
1915
|
+
break
|
|
1916
|
+
if not function_uuid:
|
|
1917
|
+
raise ValueError(
|
|
1918
|
+
f"Function '{function_name}' is not tracked locally. Run 'caraer apps push' first."
|
|
1919
|
+
)
|
|
1920
|
+
print_success(f"Fetching logs for '{function_name}'…")
|
|
1921
|
+
else:
|
|
1922
|
+
print_success("Fetching app runtime logs…")
|
|
1923
|
+
|
|
1924
|
+
# Prefer SSE for app-level follow; fall back to polling.
|
|
1925
|
+
if all_runtime and follow and (app_ctx.output or "table").lower() == "table":
|
|
1926
|
+
try:
|
|
1927
|
+
print_success("Streaming runtime logs (SSE)…")
|
|
1928
|
+
for event in projects_api.stream_runtime_logs(
|
|
1929
|
+
client, config.appUuid, since=since
|
|
1930
|
+
):
|
|
1931
|
+
if not isinstance(event, dict):
|
|
1932
|
+
continue
|
|
1933
|
+
name = str(event.get("event") or "")
|
|
1934
|
+
if name in {"ready", "done"}:
|
|
1935
|
+
continue
|
|
1936
|
+
print_logs({"entries": [event]}, seen=None, show_header=False)
|
|
1937
|
+
return
|
|
1938
|
+
except (ApiError, NotFoundError) as exc:
|
|
1939
|
+
print_success(f"SSE unavailable ({exc.status}); falling back to poll.")
|
|
1940
|
+
|
|
1941
|
+
seen: set[str] = set()
|
|
1942
|
+
first = True
|
|
1943
|
+
while True:
|
|
1944
|
+
if all_runtime:
|
|
1945
|
+
response = projects_api.get_runtime_logs(
|
|
1946
|
+
client,
|
|
1947
|
+
config.appUuid,
|
|
1948
|
+
since=since,
|
|
1949
|
+
limit=limit,
|
|
1950
|
+
)
|
|
1951
|
+
else:
|
|
1952
|
+
response = projects_api.get_function_logs(
|
|
1953
|
+
client,
|
|
1954
|
+
config.appUuid,
|
|
1955
|
+
str(function_uuid),
|
|
1956
|
+
since=since,
|
|
1957
|
+
limit=limit,
|
|
1958
|
+
)
|
|
1959
|
+
payload = response.get("data")
|
|
1960
|
+
if (app_ctx.output or "table").lower() in {"json", "yaml"}:
|
|
1961
|
+
print_data(payload, app_ctx.output)
|
|
1962
|
+
else:
|
|
1963
|
+
print_logs(payload, seen=seen, show_header=first or not follow)
|
|
1964
|
+
first = False
|
|
1965
|
+
if not follow:
|
|
1966
|
+
break
|
|
1967
|
+
time.sleep(3)
|
|
1968
|
+
|
|
1969
|
+
@app.command("dev")
|
|
1970
|
+
def app_dev(
|
|
1971
|
+
ctx: typer.Context,
|
|
1972
|
+
function: str | None = typer.Option(
|
|
1973
|
+
None,
|
|
1974
|
+
"--function",
|
|
1975
|
+
"-f",
|
|
1976
|
+
help="Optional: serve only this local function (default: all local functions).",
|
|
1977
|
+
autocompletion=complete_local_function,
|
|
1978
|
+
),
|
|
1979
|
+
port: int = typer.Option(8787, "--port"),
|
|
1980
|
+
host: str = typer.Option("127.0.0.1", "--host"),
|
|
1981
|
+
invoke_schedule: str | None = typer.Option(
|
|
1982
|
+
None,
|
|
1983
|
+
"--invoke-schedule",
|
|
1984
|
+
help="Fire a local schedule's payloadTemplate at its function, then exit.",
|
|
1985
|
+
),
|
|
1986
|
+
) -> None:
|
|
1987
|
+
"""Run a local HTTP server matching the V2 container contract.
|
|
1988
|
+
|
|
1989
|
+
Invoke via POST /functions/<name> (canonical), POST /<name>, header
|
|
1990
|
+
X-Caraer-Function, or body.functionName. Also emulates installation
|
|
1991
|
+
state/secrets/jobs and POST /inbound/<routeName>.
|
|
1992
|
+
"""
|
|
1993
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
1994
|
+
from caraer_cli.project.local_dev import serve_functions
|
|
1995
|
+
from caraer_cli.project.schema import load_workspace
|
|
1996
|
+
from caraer_cli.project.sync import list_local_function_names, resolve_local_function_name
|
|
1997
|
+
|
|
1998
|
+
root = resolve_app_root(app_file=ctx.obj.profile.app_file)
|
|
1999
|
+
config = load_workspace(root)
|
|
2000
|
+
if function:
|
|
2001
|
+
names = [resolve_local_function_name(root, config, function, interactive=False)]
|
|
2002
|
+
else:
|
|
2003
|
+
names = list_local_function_names(root, config)
|
|
2004
|
+
if not names:
|
|
2005
|
+
raise ValueError(
|
|
2006
|
+
"No local functions found under src/app/functions/. "
|
|
2007
|
+
"Add a function folder first."
|
|
2008
|
+
)
|
|
2009
|
+
base = f"http://{host}:{port}"
|
|
2010
|
+
if invoke_schedule:
|
|
2011
|
+
print_success(f"Invoking schedule '{invoke_schedule}'…")
|
|
2012
|
+
else:
|
|
2013
|
+
print_success(f"Starting local dev server on {base}")
|
|
2014
|
+
for name in names:
|
|
2015
|
+
print_success(f" POST {base}/functions/{name}")
|
|
2016
|
+
print_success(f" installation shim: {base}/api/v2/apps/<uuid>/installation/…")
|
|
2017
|
+
print_success(f" inbound: POST {base}/inbound/<routeName>")
|
|
2018
|
+
serve_functions(
|
|
2019
|
+
root,
|
|
2020
|
+
config,
|
|
2021
|
+
host=host,
|
|
2022
|
+
port=port,
|
|
2023
|
+
function_names=names,
|
|
2024
|
+
invoke_schedule=invoke_schedule,
|
|
2025
|
+
)
|