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
caraer_cli/app_sync.py ADDED
@@ -0,0 +1,697 @@
1
+ """Unified apps push / pull: manifest + functions + webhooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from caraer_cli.api import apps as apps_api
9
+ from caraer_cli.api import projects as projects_api
10
+ from caraer_cli.api.client import CaraerApiClient
11
+ from caraer_cli.apps_local import (
12
+ pull_remote_app,
13
+ sanitize_remote_app_payload,
14
+ write_pulled_app,
15
+ )
16
+ from caraer_cli.errors import ApiError
17
+ from caraer_cli.local_app import load_local_app, resolve_app_file_path
18
+ from caraer_cli.project.paths import (
19
+ app_manifest_path,
20
+ find_project_root,
21
+ workspace_file,
22
+ )
23
+ from caraer_cli.project.schema import (
24
+ ProjectConfig,
25
+ cache_project_uuid,
26
+ get_cached_project_uuid,
27
+ load_workspace,
28
+ save_project_config,
29
+ )
30
+ from caraer_cli.project.state import load_state, save_state
31
+ from caraer_cli.project.sync import pull_functions, upload_functions
32
+ from caraer_cli.project.webhooks_sync import pull_webhooks, push_webhooks
33
+ from caraer_cli.project.schedules_sync import pull_schedules, push_schedules
34
+ from caraer_cli.project.inbound_sync import pull_inbound, push_inbound
35
+ from caraer_cli.project.oauth_providers_sync import (
36
+ pull_external_oauth_providers,
37
+ push_external_oauth_providers,
38
+ )
39
+ from caraer_cli.project.marketplace_assemble import (
40
+ assemble_local_manifest,
41
+ split_marketplace_to_disk,
42
+ )
43
+ from caraer_cli.project.schema import PLATFORM_VERSION, PLATFORM_VERSION_V1
44
+ from caraer_cli.utils import deep_merge
45
+
46
+
47
+ def resolve_app_root(
48
+ *,
49
+ start: Path | None = None,
50
+ app_file: str | Path | None = None,
51
+ ) -> Path:
52
+ if app_file:
53
+ path = resolve_app_file_path(app_file)
54
+ # .../src/app/app.caraer.yaml|json → workspace root is parents[2]
55
+ if path.parent.name == "app" and path.name.startswith("app.caraer."):
56
+ return path.parents[2]
57
+ try:
58
+ return find_project_root(path.parent)
59
+ except FileNotFoundError:
60
+ return path.parent
61
+ return find_project_root(start)
62
+
63
+
64
+ def _canonicalize_app_uuid(client: CaraerApiClient, app_ref: str) -> str:
65
+ """Resolve an app name/slug/uuid to the canonical remote UUID."""
66
+ ref = app_ref.strip()
67
+ if not ref:
68
+ return ref
69
+ for fetcher in (apps_api.get_app, apps_api.get_public_app):
70
+ try:
71
+ data = fetcher(client, ref).get("data") or {}
72
+ except ApiError:
73
+ continue
74
+ if isinstance(data, dict) and data.get("uuid"):
75
+ return str(data["uuid"])
76
+ return ref
77
+
78
+
79
+ def ensure_linked(
80
+ client: CaraerApiClient,
81
+ root: Path,
82
+ config: ProjectConfig,
83
+ *,
84
+ app_uuid: str | None = None,
85
+ require_project: bool = False,
86
+ ) -> ProjectConfig:
87
+ """Ensure appUuid is set and cache developer-project uuid in state."""
88
+ resolved = (app_uuid or config.appUuid or "").strip() or None
89
+ if not resolved:
90
+ raise ValueError("App has no remote UUID. Run 'caraer apps select' or 'caraer apps push'.")
91
+ resolved = _canonicalize_app_uuid(client, resolved)
92
+ if config.appUuid != resolved:
93
+ config.appUuid = resolved
94
+ save_project_config(workspace_file(root), config)
95
+
96
+ project_uuid = get_cached_project_uuid(root)
97
+ try:
98
+ response = projects_api.create_or_get_project(client, resolved, config.name)
99
+ data = response.get("data") or {}
100
+ project_uuid = data.get("uuid") or project_uuid
101
+ if data.get("appUuid"):
102
+ config.appUuid = str(data["appUuid"])
103
+ save_project_config(workspace_file(root), config)
104
+ except ApiError:
105
+ if require_project and not project_uuid:
106
+ raise
107
+ if project_uuid:
108
+ cache_project_uuid(root, str(project_uuid))
109
+ elif require_project:
110
+ raise ValueError(
111
+ "Could not create or load a developer project for this app. "
112
+ "Check that the app has marketplace details, then retry."
113
+ )
114
+ return config
115
+
116
+
117
+ def require_project_uuid(
118
+ client: CaraerApiClient,
119
+ root: Path,
120
+ config: ProjectConfig,
121
+ *,
122
+ app_uuid: str | None = None,
123
+ ) -> str:
124
+ """Ensure a developer-project uuid is cached; create/fetch remotely if needed."""
125
+ ensure_linked(client, root, config, app_uuid=app_uuid, require_project=True)
126
+ project_uuid = get_cached_project_uuid(root)
127
+ if not project_uuid:
128
+ raise ValueError(
129
+ "Could not create or load a developer project for this app. "
130
+ "Check that the app has marketplace details, then retry."
131
+ )
132
+ return project_uuid
133
+
134
+
135
+ def push_manifest(
136
+ client: CaraerApiClient,
137
+ root: Path,
138
+ config: ProjectConfig,
139
+ *,
140
+ patch: dict[str, Any] | None = None,
141
+ strict_function_refs: bool = True,
142
+ ) -> dict[str, Any]:
143
+ if not config.appUuid:
144
+ raise ValueError("App is not linked.")
145
+ manifest_path = app_manifest_path(root, config.srcDir)
146
+ if not manifest_path.is_file():
147
+ raise FileNotFoundError(f"Missing app manifest: {manifest_path}")
148
+ local = load_local_app(manifest_path)
149
+ local = assemble_local_manifest(
150
+ root,
151
+ config,
152
+ local,
153
+ resolve_functions=True,
154
+ strict_function_refs=strict_function_refs,
155
+ )
156
+ current = apps_api.get_public_app(client, config.appUuid).get("data", {})
157
+ if not isinstance(current, dict):
158
+ current = {}
159
+ merged = deep_merge(dict(current), local)
160
+ if patch:
161
+ merged = deep_merge(merged, patch)
162
+ response = apps_api.update_public_app(client, config.appUuid, merged)
163
+ data = response.get("data") or {}
164
+ # Keep local uuid in sync (do not rewrite modular files from push response).
165
+ if data.get("uuid") and local.get("uuid") != data.get("uuid"):
166
+ raw = load_local_app(manifest_path)
167
+ raw["uuid"] = data["uuid"]
168
+ write_pulled_app(sanitize_remote_app_payload(raw), manifest_path, force=True)
169
+ return data
170
+
171
+
172
+ def create_app_from_manifest(
173
+ client: CaraerApiClient,
174
+ root: Path,
175
+ config: ProjectConfig,
176
+ ) -> dict[str, Any]:
177
+ manifest_path = app_manifest_path(root, config.srcDir)
178
+ payload = load_local_app(manifest_path)
179
+ payload = assemble_local_manifest(root, config, payload, resolve_functions=False)
180
+ payload["platformVersion"] = config.api_platform_version()
181
+ if config.is_app_platform_v2():
182
+ runtime = _resolve_app_runtime(root, config)
183
+ payload["runtime"] = runtime
184
+ if config.runtime != runtime:
185
+ config.runtime = runtime
186
+ save_project_config(workspace_file(root), config)
187
+ response = apps_api.create_public_app(client, payload)
188
+ data = response.get("data") or {}
189
+ created_uuid = data.get("uuid")
190
+ if created_uuid:
191
+ config.appUuid = str(created_uuid)
192
+ save_project_config(workspace_file(root), config)
193
+ sanitized = sanitize_remote_app_payload(data if isinstance(data, dict) else payload)
194
+ if not sanitized.get("uuid"):
195
+ sanitized["uuid"] = created_uuid
196
+ # Prefer keeping modular disk layout: split remote into files.
197
+ split_payload = split_marketplace_to_disk(root, config, sanitized)
198
+ write_pulled_app(split_payload, manifest_path, force=True)
199
+ return data
200
+
201
+
202
+ def _resolve_app_runtime(root: Path, config: ProjectConfig) -> str:
203
+ if config.runtime:
204
+ return config.runtime
205
+ from caraer_cli.project.sync import discover_local_functions
206
+
207
+ local = discover_local_functions(root, config)
208
+ if local:
209
+ return local[0][0].runtime
210
+ return "nodejs22"
211
+
212
+
213
+ def _parse_results_json(raw: Any) -> dict[str, Any]:
214
+ """Parse deploy resultsJson (string or dict) into a dict."""
215
+ import json
216
+
217
+ if isinstance(raw, dict):
218
+ return raw
219
+ if isinstance(raw, str) and raw.strip():
220
+ try:
221
+ parsed = json.loads(raw)
222
+ except json.JSONDecodeError:
223
+ return {}
224
+ return parsed if isinstance(parsed, dict) else {}
225
+ return {}
226
+
227
+
228
+ def _print_deploy_results(deploy_data: dict[str, Any]) -> None:
229
+ """Print per-item reconcile results from a deploy response."""
230
+ from caraer_cli.formatters.output import print_error, print_success, print_warning
231
+
232
+ status = str(deploy_data.get("status") or "").upper()
233
+ results = _parse_results_json(deploy_data.get("resultsJson"))
234
+ if not results and status not in {"PARTIAL", "FAILED"}:
235
+ return
236
+
237
+ if status:
238
+ printer = print_error if status == "FAILED" else (
239
+ print_warning if status == "PARTIAL" else print_success
240
+ )
241
+ printer(f"Deploy status: {status}")
242
+
243
+ for section, payload in results.items():
244
+ if section == "runtime":
245
+ continue
246
+ if isinstance(payload, list):
247
+ for item in payload:
248
+ if not isinstance(item, dict):
249
+ continue
250
+ name = item.get("name") or item.get("uuid") or "?"
251
+ if item.get("success") is True:
252
+ print_success(f" {section}/{name}: ok")
253
+ elif item.get("skipped"):
254
+ print_warning(
255
+ f" {section}/{name}: skipped"
256
+ f" ({item.get('reason') or item.get('error') or 'n/a'})"
257
+ )
258
+ else:
259
+ print_error(
260
+ f" {section}/{name}: "
261
+ f"{item.get('error') or 'failed'}"
262
+ )
263
+ elif isinstance(payload, dict):
264
+ if payload.get("success") is True:
265
+ print_success(f" {section}: ok")
266
+ elif payload.get("skipped"):
267
+ print_warning(
268
+ f" {section}: skipped"
269
+ f" ({payload.get('reason') or payload.get('error') or 'n/a'})"
270
+ )
271
+ elif payload.get("success") is False or payload.get("error"):
272
+ print_error(f" {section}: {payload.get('error') or 'failed'}")
273
+
274
+
275
+ def _print_runtime_outcome(runtime: dict[str, Any]) -> None:
276
+ """Print a clear READY-at-URL or FAILED message after wait."""
277
+ from caraer_cli.formatters.output import print_error, print_success, print_warning
278
+
279
+ if not runtime:
280
+ return
281
+ status = str(runtime.get("runtimeStatus") or "").upper()
282
+ base_url = runtime.get("runtimeBaseUrl")
283
+ error = runtime.get("runtimeError")
284
+ if status == "READY":
285
+ if base_url:
286
+ print_success(f"runtime READY at {base_url}")
287
+ else:
288
+ print_success("runtime READY")
289
+ elif status == "FAILED":
290
+ print_error(f"runtime FAILED: {error or 'unknown error'}")
291
+ elif status == "TIMEOUT":
292
+ print_warning(f"runtime wait timed out: {error or status}")
293
+ elif status:
294
+ print_warning(f"runtimeStatus={status}")
295
+
296
+
297
+ def _refresh_deploy(
298
+ client: CaraerApiClient,
299
+ project_uuid: str,
300
+ deploy_uuid: str | None,
301
+ ) -> dict[str, Any]:
302
+ """Re-fetch a deploy by UUID (best-effort) to pick up final resultsJson."""
303
+ if not deploy_uuid:
304
+ return {}
305
+ try:
306
+ rows = projects_api.list_deploys(client, project_uuid).get("data") or []
307
+ except Exception: # noqa: BLE001
308
+ return {}
309
+ if not isinstance(rows, list):
310
+ return {}
311
+ for item in rows:
312
+ if isinstance(item, dict) and str(item.get("uuid") or "") == str(deploy_uuid):
313
+ return item
314
+ return {}
315
+
316
+
317
+ def _poll_v2_runtime(
318
+ client: CaraerApiClient,
319
+ config: ProjectConfig,
320
+ *,
321
+ timeout_s: float = 300.0,
322
+ interval_s: float = 3.0,
323
+ progress: bool = False,
324
+ ) -> dict[str, Any]:
325
+ """Poll App.runtimeStatus until READY/FAILED or timeout."""
326
+ import time
327
+
328
+ from caraer_cli.formatters.output import print_success
329
+
330
+ if not config.appUuid or not config.is_app_platform_v2():
331
+ return {}
332
+ started = time.monotonic()
333
+ deadline = started + timeout_s
334
+ last: dict[str, Any] = {}
335
+ last_printed: str | None = None
336
+ last_heartbeat = 0.0
337
+ if progress:
338
+ print_success(f"Waiting for runtime READY (timeout {int(timeout_s)}s)…")
339
+ while time.monotonic() < deadline:
340
+ response = apps_api.get_app(client, config.appUuid)
341
+ data = response.get("data") if isinstance(response.get("data"), dict) else {}
342
+ last = {
343
+ "platformVersion": data.get("platformVersion"),
344
+ "runtime": data.get("runtime"),
345
+ "runtimeStatus": data.get("runtimeStatus"),
346
+ "runtimeBaseUrl": data.get("runtimeBaseUrl"),
347
+ "runtimeRevision": data.get("runtimeRevision"),
348
+ "runtimeError": data.get("runtimeError"),
349
+ }
350
+ status = str(data.get("runtimeStatus") or "").upper() or "UNKNOWN"
351
+ elapsed = time.monotonic() - started
352
+ if progress and status != last_printed:
353
+ print_success(f"runtimeStatus={status} ({int(elapsed)}s)")
354
+ last_printed = status
355
+ last_heartbeat = elapsed
356
+ elif progress and elapsed - last_heartbeat >= 15:
357
+ print_success(f"still waiting… runtimeStatus={status} ({int(elapsed)}s)")
358
+ last_heartbeat = elapsed
359
+ if status in {"READY", "FAILED"}:
360
+ if progress:
361
+ _print_runtime_outcome(last)
362
+ return last
363
+ time.sleep(interval_s)
364
+ last["runtimeStatus"] = last.get("runtimeStatus") or "TIMEOUT"
365
+ last["runtimeError"] = last.get("runtimeError") or "Timed out waiting for runtime READY"
366
+ if progress:
367
+ _print_runtime_outcome(last)
368
+ return last
369
+
370
+
371
+ def push_functions(
372
+ client: CaraerApiClient,
373
+ root: Path,
374
+ config: ProjectConfig,
375
+ *,
376
+ deploy: bool = False,
377
+ delete_missing: bool = False,
378
+ legacy: bool = False,
379
+ target: str = "production",
380
+ wait: bool = True,
381
+ version: str | None = None,
382
+ release_notes: str | None = None,
383
+ interactive: bool = True,
384
+ ) -> dict[str, Any]:
385
+ from caraer_cli.formatters.output import print_success
386
+ from caraer_cli.project.release import resolve_release_for_build
387
+ from caraer_cli.wizard.prompts import WizardCancelled
388
+
389
+ project_uuid = get_cached_project_uuid(root)
390
+ if not legacy and not project_uuid:
391
+ try:
392
+ project_uuid = require_project_uuid(client, root, config)
393
+ except (ApiError, ValueError):
394
+ project_uuid = None
395
+ if legacy or not project_uuid:
396
+ print_success("Syncing functions (legacy)…")
397
+ return {
398
+ "mode": "legacy",
399
+ **upload_functions(client, root, config, delete_missing=delete_missing),
400
+ }
401
+
402
+ try:
403
+ version, release_notes = resolve_release_for_build(
404
+ client,
405
+ project_uuid,
406
+ version=version,
407
+ release_notes=release_notes,
408
+ interactive=interactive,
409
+ )
410
+ except WizardCancelled:
411
+ raise
412
+ print_success(f"Release v{version}")
413
+
414
+ print_success("Packing project and creating build…")
415
+ archive_path = projects_api.pack_project_archive(root, config)
416
+ try:
417
+ response = projects_api.create_build(
418
+ client,
419
+ project_uuid,
420
+ archive_path,
421
+ target=target,
422
+ version=version,
423
+ release_notes=release_notes,
424
+ )
425
+ finally:
426
+ if archive_path.exists():
427
+ archive_path.unlink(missing_ok=True)
428
+
429
+ data = response.get("data") or {}
430
+ build_uuid = data.get("uuid")
431
+ build_version = data.get("version")
432
+ state = load_state(root)
433
+ state["lastBuildUuid"] = build_uuid
434
+ if build_version:
435
+ state["lastBuildVersion"] = build_version
436
+ save_state(root, state)
437
+ result: dict[str, Any] = {"mode": "build", "build": data}
438
+ version_label = f" v{build_version}" if build_version else ""
439
+ print_success(
440
+ f"Build created{version_label}: {build_uuid} "
441
+ f"(status={data.get('status') or 'unknown'})"
442
+ )
443
+
444
+ should_deploy = deploy or config.autoDeploy
445
+ if should_deploy:
446
+ print_success(f"Deploying build {build_uuid} to {target}…")
447
+ deploy_response = projects_api.deploy_build(
448
+ client, project_uuid, str(build_uuid), target=target, prune=delete_missing
449
+ )
450
+ deploy_data = deploy_response.get("data") or {}
451
+ state["lastDeployUuid"] = deploy_data.get("uuid")
452
+ save_state(root, state)
453
+ result["deploy"] = deploy_data
454
+ print_success(f"Deploy started: {deploy_data.get('uuid') or 'ok'}")
455
+ _print_deploy_results(deploy_data if isinstance(deploy_data, dict) else {})
456
+ if config.is_app_platform_v2() and wait:
457
+ result["runtime"] = _poll_v2_runtime(client, config, progress=True)
458
+ refreshed = _refresh_deploy(
459
+ client, project_uuid, deploy_data.get("uuid") if isinstance(deploy_data, dict) else None
460
+ )
461
+ if refreshed:
462
+ result["deploy"] = refreshed
463
+ _print_deploy_results(refreshed)
464
+ elif config.is_app_platform_v2() and not wait:
465
+ print_success(
466
+ "Skipping runtime wait. Check later with 'caraer apps get' "
467
+ "(runtimeStatus) or re-run with --wait."
468
+ )
469
+
470
+ # Also refresh local function UUID tracking via legacy list when possible.
471
+ try:
472
+ upload_functions(client, root, config, delete_missing=False)
473
+ except Exception: # noqa: BLE001
474
+ pass
475
+ return result
476
+
477
+
478
+ def _deploy_has_full_reconcile(functions_result: dict[str, Any]) -> bool:
479
+ """True when the backend deploy results include schedules/inbound/oauth."""
480
+ deploy = functions_result.get("deploy") if isinstance(functions_result, dict) else None
481
+ if not isinstance(deploy, dict):
482
+ return False
483
+ results = _parse_results_json(deploy.get("resultsJson"))
484
+ return any(
485
+ key in results
486
+ for key in ("schedules", "inboundRoutes", "externalOAuthProviders")
487
+ )
488
+
489
+
490
+ def push_app(
491
+ client: CaraerApiClient,
492
+ root: Path,
493
+ *,
494
+ app_uuid: str | None = None,
495
+ patch: dict[str, Any] | None = None,
496
+ deploy: bool = False,
497
+ delete_missing: bool = False,
498
+ legacy_functions: bool = False,
499
+ target: str = "production",
500
+ wait: bool = True,
501
+ version: str | None = None,
502
+ release_notes: str | None = None,
503
+ interactive: bool = True,
504
+ ) -> dict[str, Any]:
505
+ from caraer_cli.formatters.output import print_success, print_warning
506
+
507
+ config = load_workspace(root)
508
+
509
+ # Create remote app when unlinked but local manifest exists.
510
+ if not (app_uuid or config.appUuid):
511
+ print_success("Creating remote app from local manifest…")
512
+ created = create_app_from_manifest(client, root, config)
513
+ config = load_workspace(root)
514
+ manifest_result = created
515
+ else:
516
+ config = ensure_linked(client, root, config, app_uuid=app_uuid)
517
+ print_success("Pushing app manifest…")
518
+ # Soft resolve: settings/pricing always; lifecycle/app-bars wait for function UUIDs.
519
+ manifest_result = push_manifest(
520
+ client, root, config, patch=patch, strict_function_refs=False
521
+ )
522
+
523
+ config = ensure_linked(client, root, config)
524
+ if target == "sandbox":
525
+ print_warning(
526
+ "Sandbox deploys isolate Neo4j data via X-Caraer-Sandbox-Uuid, "
527
+ "but the Cloud Function runtime is shared with production."
528
+ )
529
+ functions_result = push_functions(
530
+ client,
531
+ root,
532
+ config,
533
+ deploy=deploy,
534
+ delete_missing=delete_missing,
535
+ legacy=legacy_functions,
536
+ target=target,
537
+ wait=wait,
538
+ version=version,
539
+ release_notes=release_notes,
540
+ interactive=interactive,
541
+ )
542
+ print_success("Refreshing app manifest (lifecycle / app bars)…")
543
+ try:
544
+ manifest_result = push_manifest(
545
+ client, root, config, patch=patch, strict_function_refs=True
546
+ )
547
+ except ValueError as exc:
548
+ print_success(f"Manifest refresh deferred: {exc}")
549
+
550
+ # Prefer server-side reconcile (build/deploy) when the backend reports it.
551
+ # Fall back to per-resource CRUD for older backends or non-deploy pushes.
552
+ server_reconciled = bool(deploy) and _deploy_has_full_reconcile(functions_result)
553
+ if server_reconciled:
554
+ print_success(
555
+ "Server-side deploy reconciled functions/webhooks/schedules/"
556
+ "inbound/OAuth (skipping client CRUD)."
557
+ )
558
+ webhooks_result: dict[str, Any] = {"mode": "server-reconcile"}
559
+ schedules_result: dict[str, Any] = {"mode": "server-reconcile"}
560
+ inbound_result: dict[str, Any] = {"mode": "server-reconcile"}
561
+ oauth_result: dict[str, Any] = {"mode": "server-reconcile"}
562
+ else:
563
+ print_success("Syncing webhooks…")
564
+ webhooks_result = push_webhooks(
565
+ client,
566
+ root,
567
+ config,
568
+ delete_missing=delete_missing,
569
+ )
570
+ print_success("Syncing schedules…")
571
+ schedules_result = push_schedules(
572
+ client,
573
+ root,
574
+ config,
575
+ delete_missing=delete_missing,
576
+ )
577
+ print_success("Syncing inbound routes…")
578
+ inbound_result = push_inbound(
579
+ client,
580
+ root,
581
+ config,
582
+ delete_missing=delete_missing,
583
+ )
584
+ print_success("Syncing external OAuth providers…")
585
+ oauth_result = push_external_oauth_providers(
586
+ client,
587
+ root,
588
+ config,
589
+ delete_missing=delete_missing,
590
+ )
591
+ return {
592
+ "appUuid": config.appUuid,
593
+ "manifest": {
594
+ "uuid": (manifest_result or {}).get("uuid") if isinstance(manifest_result, dict) else None,
595
+ "name": (manifest_result or {}).get("name") if isinstance(manifest_result, dict) else None,
596
+ "label": (manifest_result or {}).get("label") if isinstance(manifest_result, dict) else None,
597
+ },
598
+ "functions": functions_result,
599
+ "webhooks": webhooks_result,
600
+ "schedules": schedules_result,
601
+ "inbound": inbound_result,
602
+ "externalOAuthProviders": oauth_result,
603
+ "serverReconcile": server_reconciled,
604
+ }
605
+
606
+
607
+ def pull_app_full(
608
+ client: CaraerApiClient,
609
+ *,
610
+ app_uuid: str,
611
+ file: str | Path | None = None,
612
+ directory: str | Path | None = None,
613
+ existing_app_file: str | None = None,
614
+ force: bool = True,
615
+ ) -> dict[str, Any]:
616
+ payload, app_file = pull_remote_app(
617
+ client,
618
+ app_uuid,
619
+ file=file,
620
+ directory=directory,
621
+ existing_app_file=existing_app_file,
622
+ force=force,
623
+ )
624
+ # Prefer the canonical UUID from the remote payload (name/slug args may resolve).
625
+ linked_uuid = str(payload.get("uuid") or app_uuid).strip() or app_uuid
626
+ root = resolve_app_root(app_file=app_file)
627
+ config = load_workspace(root)
628
+ dirty = False
629
+ if config.appUuid != linked_uuid:
630
+ config.appUuid = linked_uuid
631
+ dirty = True
632
+
633
+ # Align local platformVersion / runtime with the remote AppDTO.
634
+ remote_full = apps_api.get_public_app(client, linked_uuid).get("data") or {}
635
+ if not isinstance(remote_full, dict):
636
+ remote_full = {}
637
+ remote_platform = remote_full.get("platformVersion")
638
+ if remote_platform == 2 or remote_platform == "2":
639
+ if config.platformVersion != PLATFORM_VERSION:
640
+ config.platformVersion = PLATFORM_VERSION
641
+ dirty = True
642
+ elif remote_platform == 1 or remote_platform == "1":
643
+ if config.platformVersion != PLATFORM_VERSION_V1:
644
+ config.platformVersion = PLATFORM_VERSION_V1
645
+ dirty = True
646
+ remote_runtime = remote_full.get("runtime") or payload.get("runtime")
647
+ if isinstance(remote_runtime, str) and remote_runtime.strip():
648
+ normalized = remote_runtime.strip().lower()
649
+ if normalized in {"nodejs22", "python312"} and config.runtime != normalized:
650
+ config.runtime = normalized
651
+ dirty = True
652
+ if dirty:
653
+ save_project_config(workspace_file(root), config)
654
+ ensure_linked(client, root, config, app_uuid=linked_uuid)
655
+
656
+ functions_result: dict[str, Any] = {"functions": [], "error": None}
657
+ webhooks_result: dict[str, Any] = {"webhooks": [], "error": None}
658
+ schedules_result: dict[str, Any] = {"schedules": [], "error": None}
659
+ inbound_result: dict[str, Any] = {"inbound": [], "error": None}
660
+ oauth_result: dict[str, Any] = {"providers": [], "error": None}
661
+ try:
662
+ functions_result = pull_functions(client, root, config)
663
+ except Exception as exc: # noqa: BLE001
664
+ functions_result = {"functions": [], "error": str(exc)}
665
+ try:
666
+ webhooks_result = pull_webhooks(client, root, config)
667
+ except Exception as exc: # noqa: BLE001
668
+ webhooks_result = {"webhooks": [], "error": str(exc)}
669
+ try:
670
+ schedules_result = {"count": pull_schedules(client, root, config)}
671
+ except Exception as exc: # noqa: BLE001
672
+ schedules_result = {"schedules": [], "error": str(exc)}
673
+ try:
674
+ inbound_result = {"count": pull_inbound(client, root, config)}
675
+ except Exception as exc: # noqa: BLE001
676
+ inbound_result = {"inbound": [], "error": str(exc)}
677
+ try:
678
+ oauth_result = {"count": pull_external_oauth_providers(client, root, config)}
679
+ except Exception as exc: # noqa: BLE001
680
+ oauth_result = {"providers": [], "error": str(exc)}
681
+ return {
682
+ "appUuid": linked_uuid,
683
+ "app_file": str(app_file),
684
+ "root": str(root),
685
+ "manifest": {
686
+ "uuid": payload.get("uuid"),
687
+ "name": payload.get("name"),
688
+ "label": payload.get("label"),
689
+ },
690
+ "platformVersion": config.platformVersion,
691
+ "runtime": config.runtime,
692
+ "functions": functions_result,
693
+ "webhooks": webhooks_result,
694
+ "schedules": schedules_result,
695
+ "inbound": inbound_result,
696
+ "externalOAuthProviders": oauth_result,
697
+ }