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,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