caraer-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caraer_cli/__init__.py +4 -0
- caraer_cli/api/__init__.py +1 -0
- caraer_cli/api/apps.py +78 -0
- caraer_cli/api/auth.py +53 -0
- caraer_cli/api/client.py +179 -0
- caraer_cli/api/functions.py +72 -0
- caraer_cli/api/installation.py +93 -0
- caraer_cli/api/integration_runtime.py +77 -0
- caraer_cli/api/projects.py +213 -0
- caraer_cli/api/sandboxes.py +29 -0
- caraer_cli/api/webhooks.py +59 -0
- caraer_cli/app_sync.py +697 -0
- caraer_cli/apps_local.py +274 -0
- caraer_cli/commands/__init__.py +1 -0
- caraer_cli/commands/apps.py +2025 -0
- caraer_cli/commands/auth.py +173 -0
- caraer_cli/commands/company.py +68 -0
- caraer_cli/commands/installation.py +182 -0
- caraer_cli/commands/profile.py +239 -0
- caraer_cli/commands/publish.py +40 -0
- caraer_cli/commands/sandbox.py +92 -0
- caraer_cli/commands/skill.py +165 -0
- caraer_cli/commands/webhooks.py +68 -0
- caraer_cli/completion.py +77 -0
- caraer_cli/completion_callbacks.py +162 -0
- caraer_cli/context.py +41 -0
- caraer_cli/errors.py +84 -0
- caraer_cli/formatters/__init__.py +1 -0
- caraer_cli/formatters/output.py +381 -0
- caraer_cli/formatters/timestamps.py +98 -0
- caraer_cli/install_completion.py +38 -0
- caraer_cli/local_app.py +135 -0
- caraer_cli/main.py +104 -0
- caraer_cli/models/__init__.py +1 -0
- caraer_cli/models/api.py +52 -0
- caraer_cli/project/__init__.py +17 -0
- caraer_cli/project/app_bars_sync.py +154 -0
- caraer_cli/project/app_manifest_template.py +68 -0
- caraer_cli/project/inbound_sync.py +192 -0
- caraer_cli/project/json_schemas.py +66 -0
- caraer_cli/project/lifecycle_sync.py +119 -0
- caraer_cli/project/local_dev.py +825 -0
- caraer_cli/project/manifest_edit.py +51 -0
- caraer_cli/project/marketplace_assemble.py +165 -0
- caraer_cli/project/oauth_providers_sync.py +180 -0
- caraer_cli/project/paths.py +102 -0
- caraer_cli/project/pricing_sync.py +81 -0
- caraer_cli/project/push_plan.py +590 -0
- caraer_cli/project/release.py +128 -0
- caraer_cli/project/scaffold.py +537 -0
- caraer_cli/project/schedules_sync.py +170 -0
- caraer_cli/project/schema.py +160 -0
- caraer_cli/project/settings_sync.py +77 -0
- caraer_cli/project/state.py +51 -0
- caraer_cli/project/sync.py +308 -0
- caraer_cli/project/typegen.py +278 -0
- caraer_cli/project/validate_app.py +1215 -0
- caraer_cli/project/webhooks_sync.py +233 -0
- caraer_cli/resolve.py +55 -0
- caraer_cli/schemas/app.caraer.schema.json +175 -0
- caraer_cli/schemas/function.caraer.schema.json +29 -0
- caraer_cli/schemas/inbound.caraer.schema.json +49 -0
- caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
- caraer_cli/schemas/schedule.caraer.schema.json +39 -0
- caraer_cli/schemas/webhook.caraer.schema.json +50 -0
- caraer_cli/skills/.gitkeep +2 -0
- caraer_cli/skills/caraer-apps/SKILL.md +172 -0
- caraer_cli/skills/caraer-apps/reference.md +163 -0
- caraer_cli/state/__init__.py +1 -0
- caraer_cli/state/config.py +148 -0
- caraer_cli/state/session.py +98 -0
- caraer_cli/utils.py +40 -0
- caraer_cli/wizard/__init__.py +9 -0
- caraer_cli/wizard/catalog.py +170 -0
- caraer_cli/wizard/marketplace.py +350 -0
- caraer_cli/wizard/prompts.py +99 -0
- caraer_cli/wizard/public_app.py +519 -0
- caraer_cli/wizard/scope_macros.py +214 -0
- caraer_cli-0.1.2.dist-info/METADATA +266 -0
- caraer_cli-0.1.2.dist-info/RECORD +83 -0
- caraer_cli-0.1.2.dist-info/WHEEL +4 -0
- caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
- caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
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
|
+
}
|