caraer-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caraer_cli/__init__.py +4 -0
- caraer_cli/api/__init__.py +1 -0
- caraer_cli/api/apps.py +78 -0
- caraer_cli/api/auth.py +53 -0
- caraer_cli/api/client.py +179 -0
- caraer_cli/api/functions.py +72 -0
- caraer_cli/api/installation.py +93 -0
- caraer_cli/api/integration_runtime.py +77 -0
- caraer_cli/api/projects.py +213 -0
- caraer_cli/api/sandboxes.py +29 -0
- caraer_cli/api/webhooks.py +59 -0
- caraer_cli/app_sync.py +697 -0
- caraer_cli/apps_local.py +274 -0
- caraer_cli/commands/__init__.py +1 -0
- caraer_cli/commands/apps.py +2025 -0
- caraer_cli/commands/auth.py +173 -0
- caraer_cli/commands/company.py +68 -0
- caraer_cli/commands/installation.py +182 -0
- caraer_cli/commands/profile.py +239 -0
- caraer_cli/commands/publish.py +40 -0
- caraer_cli/commands/sandbox.py +92 -0
- caraer_cli/commands/skill.py +165 -0
- caraer_cli/commands/webhooks.py +68 -0
- caraer_cli/completion.py +77 -0
- caraer_cli/completion_callbacks.py +162 -0
- caraer_cli/context.py +41 -0
- caraer_cli/errors.py +84 -0
- caraer_cli/formatters/__init__.py +1 -0
- caraer_cli/formatters/output.py +381 -0
- caraer_cli/formatters/timestamps.py +98 -0
- caraer_cli/install_completion.py +38 -0
- caraer_cli/local_app.py +135 -0
- caraer_cli/main.py +104 -0
- caraer_cli/models/__init__.py +1 -0
- caraer_cli/models/api.py +52 -0
- caraer_cli/project/__init__.py +17 -0
- caraer_cli/project/app_bars_sync.py +154 -0
- caraer_cli/project/app_manifest_template.py +68 -0
- caraer_cli/project/inbound_sync.py +192 -0
- caraer_cli/project/json_schemas.py +66 -0
- caraer_cli/project/lifecycle_sync.py +119 -0
- caraer_cli/project/local_dev.py +825 -0
- caraer_cli/project/manifest_edit.py +51 -0
- caraer_cli/project/marketplace_assemble.py +165 -0
- caraer_cli/project/oauth_providers_sync.py +180 -0
- caraer_cli/project/paths.py +102 -0
- caraer_cli/project/pricing_sync.py +81 -0
- caraer_cli/project/push_plan.py +590 -0
- caraer_cli/project/release.py +128 -0
- caraer_cli/project/scaffold.py +537 -0
- caraer_cli/project/schedules_sync.py +170 -0
- caraer_cli/project/schema.py +160 -0
- caraer_cli/project/settings_sync.py +77 -0
- caraer_cli/project/state.py +51 -0
- caraer_cli/project/sync.py +308 -0
- caraer_cli/project/typegen.py +278 -0
- caraer_cli/project/validate_app.py +1215 -0
- caraer_cli/project/webhooks_sync.py +233 -0
- caraer_cli/resolve.py +55 -0
- caraer_cli/schemas/app.caraer.schema.json +175 -0
- caraer_cli/schemas/function.caraer.schema.json +29 -0
- caraer_cli/schemas/inbound.caraer.schema.json +49 -0
- caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
- caraer_cli/schemas/schedule.caraer.schema.json +39 -0
- caraer_cli/schemas/webhook.caraer.schema.json +50 -0
- caraer_cli/skills/.gitkeep +2 -0
- caraer_cli/skills/caraer-apps/SKILL.md +172 -0
- caraer_cli/skills/caraer-apps/reference.md +163 -0
- caraer_cli/state/__init__.py +1 -0
- caraer_cli/state/config.py +148 -0
- caraer_cli/state/session.py +98 -0
- caraer_cli/utils.py +40 -0
- caraer_cli/wizard/__init__.py +9 -0
- caraer_cli/wizard/catalog.py +170 -0
- caraer_cli/wizard/marketplace.py +350 -0
- caraer_cli/wizard/prompts.py +99 -0
- caraer_cli/wizard/public_app.py +519 -0
- caraer_cli/wizard/scope_macros.py +214 -0
- caraer_cli-0.1.2.dist-info/METADATA +266 -0
- caraer_cli-0.1.2.dist-info/RECORD +83 -0
- caraer_cli-0.1.2.dist-info/WHEEL +4 -0
- caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
- caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Compute a dry-run / confirmation plan for ``caraer apps push``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from caraer_cli.api import apps as apps_api
|
|
12
|
+
from caraer_cli.api import functions as functions_api
|
|
13
|
+
from caraer_cli.api import integration_runtime as runtime_api
|
|
14
|
+
from caraer_cli.api import webhooks as webhook_api
|
|
15
|
+
from caraer_cli.api.client import CaraerApiClient
|
|
16
|
+
from caraer_cli.formatters.output import console, print_success, print_warning
|
|
17
|
+
from caraer_cli.local_app import load_local_app
|
|
18
|
+
from caraer_cli.project.inbound_sync import discover_local_inbound
|
|
19
|
+
from caraer_cli.project.oauth_providers_sync import discover_local_providers
|
|
20
|
+
from caraer_cli.project.paths import app_manifest_path
|
|
21
|
+
from caraer_cli.project.schedules_sync import discover_local_schedules
|
|
22
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
23
|
+
from caraer_cli.project.state import load_state
|
|
24
|
+
from caraer_cli.project.sync import discover_local_functions
|
|
25
|
+
from caraer_cli.project.webhooks_sync import discover_local_webhooks
|
|
26
|
+
|
|
27
|
+
ACTIONS = ("create", "update", "delete", "unchanged")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_push_plan(
|
|
31
|
+
client: CaraerApiClient,
|
|
32
|
+
root: Path,
|
|
33
|
+
config: ProjectConfig,
|
|
34
|
+
*,
|
|
35
|
+
delete_missing: bool = False,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Compare local app resources to remote and return a push plan.
|
|
38
|
+
|
|
39
|
+
Each resource entry has an ``action`` of create / update / delete / unchanged.
|
|
40
|
+
Deletes only appear when ``delete_missing`` is True.
|
|
41
|
+
"""
|
|
42
|
+
remote_app = _fetch_remote_app(client, config.appUuid)
|
|
43
|
+
linked = bool(config.appUuid and remote_app)
|
|
44
|
+
|
|
45
|
+
plan: dict[str, Any] = {
|
|
46
|
+
"appUuid": config.appUuid,
|
|
47
|
+
"linked": linked,
|
|
48
|
+
"deleteMissing": delete_missing,
|
|
49
|
+
"manifest": _plan_manifest(root, config, remote_app),
|
|
50
|
+
"functions": _plan_functions(client, root, config, delete_missing=delete_missing),
|
|
51
|
+
"webhooks": _plan_webhooks(client, root, config, delete_missing=delete_missing),
|
|
52
|
+
"schedules": _plan_schedules(client, root, config, delete_missing=delete_missing),
|
|
53
|
+
"inbound": _plan_inbound(client, root, config, delete_missing=delete_missing),
|
|
54
|
+
"externalOAuthProviders": _plan_oauth(
|
|
55
|
+
client, root, config, delete_missing=delete_missing
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
plan["counts"] = _count_actions(plan)
|
|
59
|
+
return plan
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_push_plan(plan: dict[str, Any]) -> None:
|
|
63
|
+
"""Render a push plan with Rich tables and a short summary."""
|
|
64
|
+
counts = plan.get("counts") or {}
|
|
65
|
+
app_uuid = plan.get("appUuid") or "(unlinked — will create)"
|
|
66
|
+
print_success(f"Push plan for app {app_uuid}")
|
|
67
|
+
|
|
68
|
+
manifest = plan.get("manifest") or {}
|
|
69
|
+
action = str(manifest.get("action") or "unchanged")
|
|
70
|
+
label = manifest.get("label") or ""
|
|
71
|
+
print_success(f" Manifest: {action}" + (f" ({label})" if label else ""))
|
|
72
|
+
for field, change in (manifest.get("changes") or {}).items():
|
|
73
|
+
print_warning(f" {field}: {change.get('remote')!r} → {change.get('local')!r}")
|
|
74
|
+
|
|
75
|
+
for kind, title in (
|
|
76
|
+
("functions", "Functions"),
|
|
77
|
+
("webhooks", "Webhooks"),
|
|
78
|
+
("schedules", "Schedules"),
|
|
79
|
+
("inbound", "Inbound routes"),
|
|
80
|
+
("externalOAuthProviders", "External OAuth providers"),
|
|
81
|
+
):
|
|
82
|
+
rows = plan.get(kind) or []
|
|
83
|
+
if not rows:
|
|
84
|
+
print_success(f" {title}: (none)")
|
|
85
|
+
continue
|
|
86
|
+
table = Table(title=title, show_header=True, header_style="bold")
|
|
87
|
+
table.add_column("Name")
|
|
88
|
+
table.add_column("Action")
|
|
89
|
+
for row in rows:
|
|
90
|
+
if not isinstance(row, dict):
|
|
91
|
+
continue
|
|
92
|
+
name = str(row.get("name") or row.get("topic") or "?")
|
|
93
|
+
act = str(row.get("action") or "")
|
|
94
|
+
style = {
|
|
95
|
+
"create": "green",
|
|
96
|
+
"update": "yellow",
|
|
97
|
+
"delete": "red",
|
|
98
|
+
"unchanged": "dim",
|
|
99
|
+
}.get(act)
|
|
100
|
+
table.add_row(name, act, style=style)
|
|
101
|
+
console.print(table)
|
|
102
|
+
|
|
103
|
+
print_success(
|
|
104
|
+
"Summary: "
|
|
105
|
+
f"{counts.get('create', 0)} create, "
|
|
106
|
+
f"{counts.get('update', 0)} update, "
|
|
107
|
+
f"{counts.get('delete', 0)} delete, "
|
|
108
|
+
f"{counts.get('unchanged', 0)} unchanged"
|
|
109
|
+
)
|
|
110
|
+
if plan.get("deleteMissing") is False and any(
|
|
111
|
+
isinstance(r, dict) and r.get("action") == "remote-only"
|
|
112
|
+
for kind in ("functions", "webhooks", "schedules", "inbound", "externalOAuthProviders")
|
|
113
|
+
for r in (plan.get(kind) or [])
|
|
114
|
+
):
|
|
115
|
+
print_warning(
|
|
116
|
+
"Remote-only resources are listed but will not be deleted "
|
|
117
|
+
"(pass --delete-missing to remove them)."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fetch_remote_app(client: CaraerApiClient, app_uuid: str | None) -> dict[str, Any]:
|
|
122
|
+
if not app_uuid:
|
|
123
|
+
return {}
|
|
124
|
+
try:
|
|
125
|
+
response = apps_api.get_public_app(client, app_uuid)
|
|
126
|
+
data = response.get("data")
|
|
127
|
+
return data if isinstance(data, dict) else {}
|
|
128
|
+
except Exception: # noqa: BLE001
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _manifest_description(payload: dict[str, Any]) -> str:
|
|
133
|
+
details = payload.get("details")
|
|
134
|
+
if isinstance(details, dict) and details.get("description") is not None:
|
|
135
|
+
return str(details.get("description") or "")
|
|
136
|
+
return str(payload.get("description") or "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _plan_manifest(
|
|
140
|
+
root: Path,
|
|
141
|
+
config: ProjectConfig,
|
|
142
|
+
remote_app: dict[str, Any],
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
manifest_path = app_manifest_path(root, config.srcDir)
|
|
145
|
+
local: dict[str, Any] = {}
|
|
146
|
+
if manifest_path.is_file():
|
|
147
|
+
try:
|
|
148
|
+
local = load_local_app(manifest_path)
|
|
149
|
+
except Exception: # noqa: BLE001
|
|
150
|
+
local = {}
|
|
151
|
+
local_label = str(local.get("label") or "")
|
|
152
|
+
local_desc = _manifest_description(local)
|
|
153
|
+
entry: dict[str, Any] = {
|
|
154
|
+
"name": str(local.get("name") or config.name or ""),
|
|
155
|
+
"label": local_label,
|
|
156
|
+
"description": local_desc,
|
|
157
|
+
"changes": {},
|
|
158
|
+
}
|
|
159
|
+
if not config.appUuid or not remote_app:
|
|
160
|
+
entry["action"] = "create"
|
|
161
|
+
return entry
|
|
162
|
+
|
|
163
|
+
remote_label = str(remote_app.get("label") or "")
|
|
164
|
+
remote_desc = _manifest_description(remote_app)
|
|
165
|
+
changes: dict[str, Any] = {}
|
|
166
|
+
if local_label != remote_label:
|
|
167
|
+
changes["label"] = {"local": local_label, "remote": remote_label}
|
|
168
|
+
if local_desc != remote_desc:
|
|
169
|
+
changes["description"] = {"local": local_desc, "remote": remote_desc}
|
|
170
|
+
entry["changes"] = changes
|
|
171
|
+
entry["action"] = "update" if changes else "unchanged"
|
|
172
|
+
return entry
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _hash_function(
|
|
176
|
+
runtime: str,
|
|
177
|
+
description: str,
|
|
178
|
+
code: str,
|
|
179
|
+
source_files: dict[str, str],
|
|
180
|
+
) -> str:
|
|
181
|
+
import hashlib
|
|
182
|
+
|
|
183
|
+
extras = json.dumps(source_files, sort_keys=True) if source_files else ""
|
|
184
|
+
return hashlib.sha256(
|
|
185
|
+
f"{runtime}\n{description}\n{code}\n{extras}".encode("utf-8")
|
|
186
|
+
).hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _plan_functions(
|
|
190
|
+
client: CaraerApiClient,
|
|
191
|
+
root: Path,
|
|
192
|
+
config: ProjectConfig,
|
|
193
|
+
*,
|
|
194
|
+
delete_missing: bool,
|
|
195
|
+
) -> list[dict[str, Any]]:
|
|
196
|
+
local = discover_local_functions(root, config)
|
|
197
|
+
remote_items = _list_remote(
|
|
198
|
+
lambda: functions_api.list_functions(client, config.appUuid, page=1, limit=200)
|
|
199
|
+
if config.appUuid
|
|
200
|
+
else {"data": []}
|
|
201
|
+
)
|
|
202
|
+
by_name = {
|
|
203
|
+
str(item.get("name") or ""): item
|
|
204
|
+
for item in remote_items
|
|
205
|
+
if isinstance(item, dict) and item.get("name")
|
|
206
|
+
}
|
|
207
|
+
by_uuid = {
|
|
208
|
+
str(item.get("uuid") or ""): item
|
|
209
|
+
for item in remote_items
|
|
210
|
+
if isinstance(item, dict) and item.get("uuid")
|
|
211
|
+
}
|
|
212
|
+
state = load_state(root)
|
|
213
|
+
fn_state = state.get("functions") or {}
|
|
214
|
+
rows: list[dict[str, Any]] = []
|
|
215
|
+
local_names: set[str] = set()
|
|
216
|
+
|
|
217
|
+
for manifest, _entry, code, source_files in local:
|
|
218
|
+
local_names.add(manifest.name)
|
|
219
|
+
existing_state = fn_state.get(manifest.name) or {}
|
|
220
|
+
existing_uuid = existing_state.get("uuid") if isinstance(existing_state, dict) else None
|
|
221
|
+
remote_item = (
|
|
222
|
+
by_uuid.get(str(existing_uuid)) if existing_uuid else by_name.get(manifest.name)
|
|
223
|
+
)
|
|
224
|
+
content_hash = _hash_function(
|
|
225
|
+
manifest.runtime, manifest.description or "", code, source_files
|
|
226
|
+
)
|
|
227
|
+
if not remote_item:
|
|
228
|
+
rows.append({"name": manifest.name, "action": "create"})
|
|
229
|
+
continue
|
|
230
|
+
remote_code = remote_item.get("code")
|
|
231
|
+
remote_files = remote_item.get("sourceFiles") or {}
|
|
232
|
+
if not isinstance(remote_files, dict):
|
|
233
|
+
remote_files = {}
|
|
234
|
+
if (
|
|
235
|
+
isinstance(existing_state, dict)
|
|
236
|
+
and existing_state.get("hash") == content_hash
|
|
237
|
+
and remote_code == code
|
|
238
|
+
and remote_files == source_files
|
|
239
|
+
):
|
|
240
|
+
rows.append({"name": manifest.name, "action": "unchanged"})
|
|
241
|
+
elif (
|
|
242
|
+
remote_code == code
|
|
243
|
+
and remote_files == source_files
|
|
244
|
+
and str(remote_item.get("runtime") or "") == manifest.runtime
|
|
245
|
+
and str(remote_item.get("description") or "") == (manifest.description or "")
|
|
246
|
+
):
|
|
247
|
+
rows.append({"name": manifest.name, "action": "unchanged"})
|
|
248
|
+
else:
|
|
249
|
+
rows.append({"name": manifest.name, "action": "update"})
|
|
250
|
+
|
|
251
|
+
for name, item in by_name.items():
|
|
252
|
+
if name in local_names:
|
|
253
|
+
continue
|
|
254
|
+
action = "delete" if delete_missing else "remote-only"
|
|
255
|
+
rows.append(
|
|
256
|
+
{
|
|
257
|
+
"name": name,
|
|
258
|
+
"action": action,
|
|
259
|
+
"uuid": item.get("uuid"),
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
return rows
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _webhook_match_key(item: dict[str, Any]) -> str:
|
|
266
|
+
return f"{item.get('topic') or ''}|{item.get('deliveryMode') or ''}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _webhook_compare_payload(item: dict[str, Any]) -> dict[str, Any]:
|
|
270
|
+
payload: dict[str, Any] = {}
|
|
271
|
+
for key in (
|
|
272
|
+
"topic",
|
|
273
|
+
"deliveryMode",
|
|
274
|
+
"webhookFormat",
|
|
275
|
+
"description",
|
|
276
|
+
"url",
|
|
277
|
+
"enabled",
|
|
278
|
+
):
|
|
279
|
+
if key in item and item[key] is not None:
|
|
280
|
+
payload[key] = item[key]
|
|
281
|
+
sf = item.get("serverlessFunction")
|
|
282
|
+
if isinstance(sf, dict):
|
|
283
|
+
if sf.get("name"):
|
|
284
|
+
payload["serverlessFunction"] = {"name": sf["name"]}
|
|
285
|
+
elif sf.get("uuid"):
|
|
286
|
+
payload["serverlessFunction"] = {"uuid": sf["uuid"]}
|
|
287
|
+
return payload
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _plan_webhooks(
|
|
291
|
+
client: CaraerApiClient,
|
|
292
|
+
root: Path,
|
|
293
|
+
config: ProjectConfig,
|
|
294
|
+
*,
|
|
295
|
+
delete_missing: bool,
|
|
296
|
+
) -> list[dict[str, Any]]:
|
|
297
|
+
local = discover_local_webhooks(root, config)
|
|
298
|
+
remote_items = _list_remote(
|
|
299
|
+
lambda: webhook_api.list_webhooks(client, config.appUuid, page=1, limit=200)
|
|
300
|
+
if config.appUuid
|
|
301
|
+
else {"data": []}
|
|
302
|
+
)
|
|
303
|
+
by_uuid = {
|
|
304
|
+
str(i.get("uuid")): i for i in remote_items if isinstance(i, dict) and i.get("uuid")
|
|
305
|
+
}
|
|
306
|
+
by_key = {
|
|
307
|
+
_webhook_match_key(i): i for i in remote_items if isinstance(i, dict)
|
|
308
|
+
}
|
|
309
|
+
state = load_state(root)
|
|
310
|
+
wh_state = state.get("webhooks") or {}
|
|
311
|
+
rows: list[dict[str, Any]] = []
|
|
312
|
+
local_uuids: set[str] = set()
|
|
313
|
+
local_keys: set[str] = set()
|
|
314
|
+
|
|
315
|
+
for path, raw in local:
|
|
316
|
+
key = _webhook_match_key(raw)
|
|
317
|
+
local_keys.add(key)
|
|
318
|
+
tracked = wh_state.get(path.stem) if isinstance(wh_state, dict) else None
|
|
319
|
+
existing_uuid = str(
|
|
320
|
+
raw.get("uuid")
|
|
321
|
+
or (tracked.get("uuid") if isinstance(tracked, dict) else "")
|
|
322
|
+
or ""
|
|
323
|
+
)
|
|
324
|
+
remote_item = by_uuid.get(existing_uuid) if existing_uuid else by_key.get(key)
|
|
325
|
+
name = str(raw.get("topic") or path.stem)
|
|
326
|
+
if not remote_item:
|
|
327
|
+
rows.append({"name": name, "action": "create", "file": path.name})
|
|
328
|
+
continue
|
|
329
|
+
uuid = str(remote_item.get("uuid") or "")
|
|
330
|
+
if uuid:
|
|
331
|
+
local_uuids.add(uuid)
|
|
332
|
+
if _webhook_compare_payload(raw) == _webhook_compare_payload(remote_item):
|
|
333
|
+
rows.append({"name": name, "action": "unchanged", "file": path.name, "uuid": uuid})
|
|
334
|
+
else:
|
|
335
|
+
rows.append({"name": name, "action": "update", "file": path.name, "uuid": uuid})
|
|
336
|
+
|
|
337
|
+
for item in remote_items:
|
|
338
|
+
if not isinstance(item, dict):
|
|
339
|
+
continue
|
|
340
|
+
uuid = str(item.get("uuid") or "")
|
|
341
|
+
if not uuid or uuid in local_uuids:
|
|
342
|
+
continue
|
|
343
|
+
if _webhook_match_key(item) in local_keys:
|
|
344
|
+
continue
|
|
345
|
+
action = "delete" if delete_missing else "remote-only"
|
|
346
|
+
rows.append(
|
|
347
|
+
{
|
|
348
|
+
"name": str(item.get("topic") or uuid),
|
|
349
|
+
"action": action,
|
|
350
|
+
"uuid": uuid,
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
return rows
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _sf_name(item: dict[str, Any]) -> str | None:
|
|
357
|
+
sf = item.get("serverlessFunction")
|
|
358
|
+
if isinstance(sf, dict):
|
|
359
|
+
if sf.get("name"):
|
|
360
|
+
return str(sf["name"])
|
|
361
|
+
if sf.get("uuid"):
|
|
362
|
+
return str(sf["uuid"])
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _schedule_compare(item: dict[str, Any]) -> dict[str, Any]:
|
|
367
|
+
return {
|
|
368
|
+
"name": item.get("name"),
|
|
369
|
+
"label": item.get("label"),
|
|
370
|
+
"schedule": item.get("schedule"),
|
|
371
|
+
"enabled": item.get("enabled"),
|
|
372
|
+
"payloadTemplate": item.get("payloadTemplate"),
|
|
373
|
+
"serverlessFunction": _sf_name(item),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _plan_schedules(
|
|
378
|
+
client: CaraerApiClient,
|
|
379
|
+
root: Path,
|
|
380
|
+
config: ProjectConfig,
|
|
381
|
+
*,
|
|
382
|
+
delete_missing: bool,
|
|
383
|
+
) -> list[dict[str, Any]]:
|
|
384
|
+
local = discover_local_schedules(root, config)
|
|
385
|
+
remote_items = _list_remote(
|
|
386
|
+
lambda: runtime_api.list_schedules(client, config.appUuid)
|
|
387
|
+
if config.appUuid
|
|
388
|
+
else {"data": []}
|
|
389
|
+
)
|
|
390
|
+
by_uuid = {
|
|
391
|
+
str(i["uuid"]): i for i in remote_items if isinstance(i, dict) and i.get("uuid")
|
|
392
|
+
}
|
|
393
|
+
by_name = {
|
|
394
|
+
str(i["name"]): i for i in remote_items if isinstance(i, dict) and i.get("name")
|
|
395
|
+
}
|
|
396
|
+
rows: list[dict[str, Any]] = []
|
|
397
|
+
kept: set[str] = set()
|
|
398
|
+
|
|
399
|
+
for path, raw in local:
|
|
400
|
+
name = str(raw.get("name") or path.stem)
|
|
401
|
+
uuid = str(raw.get("uuid") or "")
|
|
402
|
+
existing = by_uuid.get(uuid) or by_name.get(name)
|
|
403
|
+
if not existing:
|
|
404
|
+
rows.append({"name": name, "action": "create", "file": path.name})
|
|
405
|
+
continue
|
|
406
|
+
remote_uuid = str(existing.get("uuid") or "")
|
|
407
|
+
if remote_uuid:
|
|
408
|
+
kept.add(remote_uuid)
|
|
409
|
+
if _schedule_compare(raw) == _schedule_compare(existing):
|
|
410
|
+
rows.append({"name": name, "action": "unchanged", "uuid": remote_uuid})
|
|
411
|
+
else:
|
|
412
|
+
rows.append({"name": name, "action": "update", "uuid": remote_uuid})
|
|
413
|
+
|
|
414
|
+
for uuid, item in by_uuid.items():
|
|
415
|
+
if uuid in kept:
|
|
416
|
+
continue
|
|
417
|
+
action = "delete" if delete_missing else "remote-only"
|
|
418
|
+
rows.append(
|
|
419
|
+
{
|
|
420
|
+
"name": str(item.get("name") or uuid),
|
|
421
|
+
"action": action,
|
|
422
|
+
"uuid": uuid,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
return rows
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _inbound_compare(item: dict[str, Any]) -> dict[str, Any]:
|
|
429
|
+
return {
|
|
430
|
+
"name": item.get("name"),
|
|
431
|
+
"label": item.get("label"),
|
|
432
|
+
"authMode": item.get("authMode"),
|
|
433
|
+
"enqueue": item.get("enqueue"),
|
|
434
|
+
"serverlessFunction": _sf_name(item),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _plan_inbound(
|
|
439
|
+
client: CaraerApiClient,
|
|
440
|
+
root: Path,
|
|
441
|
+
config: ProjectConfig,
|
|
442
|
+
*,
|
|
443
|
+
delete_missing: bool,
|
|
444
|
+
) -> list[dict[str, Any]]:
|
|
445
|
+
local = discover_local_inbound(root, config)
|
|
446
|
+
remote_items = _list_remote(
|
|
447
|
+
lambda: runtime_api.list_inbound_routes(client, config.appUuid)
|
|
448
|
+
if config.appUuid
|
|
449
|
+
else {"data": []}
|
|
450
|
+
)
|
|
451
|
+
by_uuid = {
|
|
452
|
+
str(i["uuid"]): i for i in remote_items if isinstance(i, dict) and i.get("uuid")
|
|
453
|
+
}
|
|
454
|
+
by_name = {
|
|
455
|
+
str(i["name"]): i for i in remote_items if isinstance(i, dict) and i.get("name")
|
|
456
|
+
}
|
|
457
|
+
rows: list[dict[str, Any]] = []
|
|
458
|
+
kept: set[str] = set()
|
|
459
|
+
|
|
460
|
+
for path, raw in local:
|
|
461
|
+
name = str(raw.get("name") or path.stem)
|
|
462
|
+
uuid = str(raw.get("uuid") or "")
|
|
463
|
+
existing = by_uuid.get(uuid) or by_name.get(name)
|
|
464
|
+
if not existing:
|
|
465
|
+
rows.append({"name": name, "action": "create", "file": path.name})
|
|
466
|
+
continue
|
|
467
|
+
remote_uuid = str(existing.get("uuid") or "")
|
|
468
|
+
if remote_uuid:
|
|
469
|
+
kept.add(remote_uuid)
|
|
470
|
+
if _inbound_compare(raw) == _inbound_compare(existing):
|
|
471
|
+
rows.append({"name": name, "action": "unchanged", "uuid": remote_uuid})
|
|
472
|
+
else:
|
|
473
|
+
rows.append({"name": name, "action": "update", "uuid": remote_uuid})
|
|
474
|
+
|
|
475
|
+
for uuid, item in by_uuid.items():
|
|
476
|
+
if uuid in kept:
|
|
477
|
+
continue
|
|
478
|
+
action = "delete" if delete_missing else "remote-only"
|
|
479
|
+
rows.append(
|
|
480
|
+
{
|
|
481
|
+
"name": str(item.get("name") or uuid),
|
|
482
|
+
"action": action,
|
|
483
|
+
"uuid": uuid,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
return rows
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _oauth_compare(item: dict[str, Any]) -> dict[str, Any]:
|
|
490
|
+
return {
|
|
491
|
+
"name": item.get("name"),
|
|
492
|
+
"logo": item.get("logo"),
|
|
493
|
+
"authorizeUrl": item.get("authorizeUrl"),
|
|
494
|
+
"tokenUrl": item.get("tokenUrl"),
|
|
495
|
+
"clientId": item.get("clientId"),
|
|
496
|
+
"scopes": item.get("scopes"),
|
|
497
|
+
"pkce": item.get("pkce"),
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _plan_oauth(
|
|
502
|
+
client: CaraerApiClient,
|
|
503
|
+
root: Path,
|
|
504
|
+
config: ProjectConfig,
|
|
505
|
+
*,
|
|
506
|
+
delete_missing: bool,
|
|
507
|
+
) -> list[dict[str, Any]]:
|
|
508
|
+
local = discover_local_providers(root, config)
|
|
509
|
+
remote_items = _list_remote(
|
|
510
|
+
lambda: runtime_api.list_external_oauth_providers(client, config.appUuid)
|
|
511
|
+
if config.appUuid
|
|
512
|
+
else {"data": []}
|
|
513
|
+
)
|
|
514
|
+
by_uuid = {
|
|
515
|
+
str(i["uuid"]): i for i in remote_items if isinstance(i, dict) and i.get("uuid")
|
|
516
|
+
}
|
|
517
|
+
by_name = {
|
|
518
|
+
str(i["name"]): i for i in remote_items if isinstance(i, dict) and i.get("name")
|
|
519
|
+
}
|
|
520
|
+
rows: list[dict[str, Any]] = []
|
|
521
|
+
kept: set[str] = set()
|
|
522
|
+
|
|
523
|
+
for provider in local:
|
|
524
|
+
name = str(provider.get("name") or "")
|
|
525
|
+
client_id = str(provider.get("clientId") or "").strip()
|
|
526
|
+
if not client_id:
|
|
527
|
+
# Placeholders are skipped on push.
|
|
528
|
+
rows.append({"name": name or "?", "action": "unchanged", "skipped": True})
|
|
529
|
+
continue
|
|
530
|
+
uuid = str(provider.get("uuid") or "")
|
|
531
|
+
existing = by_uuid.get(uuid) or by_name.get(name)
|
|
532
|
+
if not existing:
|
|
533
|
+
rows.append({"name": name, "action": "create"})
|
|
534
|
+
continue
|
|
535
|
+
remote_uuid = str(existing.get("uuid") or "")
|
|
536
|
+
if remote_uuid:
|
|
537
|
+
kept.add(remote_uuid)
|
|
538
|
+
has_secret = bool(str(provider.get("clientSecret") or "").strip())
|
|
539
|
+
if _oauth_compare(provider) == _oauth_compare(existing) and not has_secret:
|
|
540
|
+
rows.append({"name": name, "action": "unchanged", "uuid": remote_uuid})
|
|
541
|
+
else:
|
|
542
|
+
rows.append({"name": name, "action": "update", "uuid": remote_uuid})
|
|
543
|
+
|
|
544
|
+
for uuid, item in by_uuid.items():
|
|
545
|
+
if uuid in kept:
|
|
546
|
+
continue
|
|
547
|
+
action = "delete" if delete_missing else "remote-only"
|
|
548
|
+
rows.append(
|
|
549
|
+
{
|
|
550
|
+
"name": str(item.get("name") or uuid),
|
|
551
|
+
"action": action,
|
|
552
|
+
"uuid": uuid,
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
return rows
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _list_remote(fetcher) -> list[dict[str, Any]]:
|
|
559
|
+
try:
|
|
560
|
+
response = fetcher()
|
|
561
|
+
except Exception: # noqa: BLE001
|
|
562
|
+
return []
|
|
563
|
+
data = response.get("data") if isinstance(response, dict) else None
|
|
564
|
+
if not isinstance(data, list):
|
|
565
|
+
return []
|
|
566
|
+
return [i for i in data if isinstance(i, dict)]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _count_actions(plan: dict[str, Any]) -> dict[str, int]:
|
|
570
|
+
counts = {action: 0 for action in ACTIONS}
|
|
571
|
+
counts["remote-only"] = 0
|
|
572
|
+
manifest = plan.get("manifest")
|
|
573
|
+
if isinstance(manifest, dict):
|
|
574
|
+
action = str(manifest.get("action") or "")
|
|
575
|
+
if action in counts:
|
|
576
|
+
counts[action] += 1
|
|
577
|
+
for kind in (
|
|
578
|
+
"functions",
|
|
579
|
+
"webhooks",
|
|
580
|
+
"schedules",
|
|
581
|
+
"inbound",
|
|
582
|
+
"externalOAuthProviders",
|
|
583
|
+
):
|
|
584
|
+
for row in plan.get(kind) or []:
|
|
585
|
+
if not isinstance(row, dict):
|
|
586
|
+
continue
|
|
587
|
+
action = str(row.get("action") or "")
|
|
588
|
+
if action in counts:
|
|
589
|
+
counts[action] += 1
|
|
590
|
+
return counts
|