caraer-cli 0.1.2__py3-none-any.whl

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