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,192 @@
1
+ """Sync inbound routes between src/app/inbound/*.json and the remote API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import secrets
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from caraer_cli.api import integration_runtime as api
12
+ from caraer_cli.api.client import CaraerApiClient
13
+ from caraer_cli.project.paths import inbound_dir
14
+ from caraer_cli.project.schema import ProjectConfig
15
+ from caraer_cli.project.state import load_state, save_state
16
+
17
+ LOCAL_INBOUND_KEYS = (
18
+ "uuid",
19
+ "name",
20
+ "label",
21
+ "authMode",
22
+ "enqueue",
23
+ "sharedSecret",
24
+ "serverlessFunction",
25
+ )
26
+
27
+
28
+ def _slug(value: str) -> str:
29
+ text = value.strip().lower()
30
+ text = re.sub(r"[^a-z0-9]+", "-", text)
31
+ return text.strip("-") or "inbound"
32
+
33
+
34
+ def inbound_filename(item: dict[str, Any]) -> str:
35
+ name = str(item.get("name") or "inbound")
36
+ uuid = str(item.get("uuid") or "")[:8]
37
+ parts = [_slug(name)]
38
+ if uuid:
39
+ parts.append(uuid)
40
+ return "-".join(parts) + ".json"
41
+
42
+
43
+ def discover_local_inbound(root: Path, config: ProjectConfig) -> list[tuple[Path, dict[str, Any]]]:
44
+ base = inbound_dir(root, config.srcDir)
45
+ if not base.is_dir():
46
+ return []
47
+ found: list[tuple[Path, dict[str, Any]]] = []
48
+ for path in sorted(base.glob("*.json")):
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ if isinstance(data, dict):
51
+ found.append((path, data))
52
+ return found
53
+
54
+
55
+ def _sanitize_local(item: dict[str, Any]) -> dict[str, Any]:
56
+ payload: dict[str, Any] = {}
57
+ for key in LOCAL_INBOUND_KEYS:
58
+ if key in item and item[key] is not None:
59
+ payload[key] = item[key]
60
+ # Never persist sharedSecret after push in pulled files — keep local-only until push.
61
+ sf = payload.get("serverlessFunction")
62
+ if isinstance(sf, dict):
63
+ cleaned: dict[str, Any] = {}
64
+ if sf.get("name"):
65
+ cleaned["name"] = sf["name"]
66
+ elif sf.get("uuid"):
67
+ cleaned["uuid"] = sf["uuid"]
68
+ if cleaned:
69
+ payload["serverlessFunction"] = cleaned
70
+ else:
71
+ payload.pop("serverlessFunction", None)
72
+ return payload
73
+
74
+
75
+ def _sanitize_remote(item: dict[str, Any]) -> dict[str, Any]:
76
+ payload = _sanitize_local(item)
77
+ payload.pop("sharedSecret", None)
78
+ if item.get("hasSharedSecret"):
79
+ payload["hasSharedSecret"] = True
80
+ return payload
81
+
82
+
83
+ def _resolve_sf(payload: dict[str, Any], *, fn_by_name: dict[str, str]) -> dict[str, Any]:
84
+ out = dict(payload)
85
+ out.pop("uuid", None)
86
+ out.pop("hasSharedSecret", None)
87
+ sf = out.get("serverlessFunction")
88
+ if not isinstance(sf, dict):
89
+ return out
90
+ if sf.get("uuid"):
91
+ out["serverlessFunction"] = {"uuid": sf["uuid"]}
92
+ return out
93
+ name = sf.get("name")
94
+ if name and name in fn_by_name:
95
+ out["serverlessFunction"] = {"uuid": fn_by_name[name]}
96
+ return out
97
+ if name:
98
+ raise ValueError(
99
+ f"Inbound route references function '{name}' but no UUID is known. Push functions first."
100
+ )
101
+ return out
102
+
103
+
104
+ def pull_inbound(client: CaraerApiClient, root: Path, config: ProjectConfig) -> int:
105
+ if not config.appUuid:
106
+ raise ValueError("App is not linked.")
107
+ response = api.list_inbound_routes(client, config.appUuid)
108
+ items = response.get("data") or []
109
+ if not isinstance(items, list):
110
+ items = []
111
+ base = inbound_dir(root, config.srcDir)
112
+ base.mkdir(parents=True, exist_ok=True)
113
+ for existing in base.glob("*.json"):
114
+ existing.unlink()
115
+ state = load_state(root)
116
+ inbound_map: dict[str, str] = {}
117
+ for item in items:
118
+ if not isinstance(item, dict):
119
+ continue
120
+ sanitized = _sanitize_remote(item)
121
+ path = base / inbound_filename(sanitized)
122
+ path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
123
+ if sanitized.get("uuid") and sanitized.get("name"):
124
+ inbound_map[str(sanitized["name"])] = str(sanitized["uuid"])
125
+ state["inbound"] = inbound_map
126
+ save_state(root, state)
127
+ return len(inbound_map)
128
+
129
+
130
+ def push_inbound(
131
+ client: CaraerApiClient,
132
+ root: Path,
133
+ config: ProjectConfig,
134
+ *,
135
+ delete_missing: bool = False,
136
+ ) -> dict[str, Any]:
137
+ if not config.appUuid:
138
+ raise ValueError("App is not linked.")
139
+ state = load_state(root)
140
+ fn_state = state.get("functions") or {}
141
+ fn_by_name = {
142
+ name: str(meta["uuid"])
143
+ for name, meta in fn_state.items()
144
+ if isinstance(meta, dict) and meta.get("uuid")
145
+ }
146
+ remote = api.list_inbound_routes(client, config.appUuid).get("data") or []
147
+ remote_by_uuid = {
148
+ str(i["uuid"]): i for i in remote if isinstance(i, dict) and i.get("uuid")
149
+ }
150
+ remote_by_name = {
151
+ str(i["name"]): i for i in remote if isinstance(i, dict) and i.get("name")
152
+ }
153
+ local = discover_local_inbound(root, config)
154
+ pushed: dict[str, str] = {}
155
+ kept_uuids: set[str] = set()
156
+ for path, raw in local:
157
+ sanitized = _sanitize_local(raw)
158
+ payload = _resolve_sf(sanitized, fn_by_name=fn_by_name)
159
+ name = str(sanitized.get("name") or "")
160
+ uuid = str(sanitized.get("uuid") or "")
161
+ existing = remote_by_uuid.get(uuid) or remote_by_name.get(name)
162
+ auth_mode = str(payload.get("authMode") or "SHARED_SECRET").upper()
163
+ if (
164
+ not existing
165
+ and auth_mode == "SHARED_SECRET"
166
+ and not str(payload.get("sharedSecret") or "").strip()
167
+ ):
168
+ generated = secrets.token_urlsafe(24)
169
+ payload["sharedSecret"] = generated
170
+ # Keep locally so Pub/Sub can be configured; not written back after create.
171
+ sanitized["sharedSecret"] = generated
172
+ path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
173
+ if existing and existing.get("uuid"):
174
+ resp = api.update_inbound_route(client, config.appUuid, str(existing["uuid"]), payload)
175
+ data = resp.get("data") or existing
176
+ else:
177
+ resp = api.create_inbound_route(client, config.appUuid, payload)
178
+ data = resp.get("data") or {}
179
+ if isinstance(data, dict) and data.get("uuid"):
180
+ kept_uuids.add(str(data["uuid"]))
181
+ if data.get("name"):
182
+ pushed[str(data["name"])] = str(data["uuid"])
183
+ write_back = _sanitize_remote({**sanitized, **data})
184
+ write_back.pop("sharedSecret", None)
185
+ path.write_text(json.dumps(write_back, indent=2) + "\n", encoding="utf-8")
186
+ if delete_missing:
187
+ for uuid in remote_by_uuid:
188
+ if uuid not in kept_uuids:
189
+ api.delete_inbound_route(client, config.appUuid, uuid)
190
+ state["inbound"] = pushed
191
+ save_state(root, state)
192
+ return {"pushed": len(pushed), "deletedMissing": delete_missing}
@@ -0,0 +1,66 @@
1
+ """JSON Schema URLs and helpers for Caraer app config files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ SCHEMA_REPO_BASE = (
10
+ "https://raw.githubusercontent.com/caraer/caraer-cli/main/schemas"
11
+ )
12
+
13
+ APP_MANIFEST_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/app.caraer.schema.json"
14
+ FUNCTION_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/function.caraer.schema.json"
15
+ WEBHOOK_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/webhook.caraer.schema.json"
16
+ SCHEDULE_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/schedule.caraer.schema.json"
17
+ INBOUND_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/inbound.caraer.schema.json"
18
+ LIFECYCLE_SCHEMA_URL = f"{SCHEMA_REPO_BASE}/lifecycle.caraer.schema.json"
19
+
20
+ YAML_LANGUAGE_SERVER_COMMENT = (
21
+ f"# yaml-language-server: $schema={APP_MANIFEST_SCHEMA_URL}"
22
+ )
23
+
24
+ SCHEMA_FILENAMES = {
25
+ "app": "app.caraer.schema.json",
26
+ "function": "function.caraer.schema.json",
27
+ "webhook": "webhook.caraer.schema.json",
28
+ "schedule": "schedule.caraer.schema.json",
29
+ "inbound": "inbound.caraer.schema.json",
30
+ "lifecycle": "lifecycle.caraer.schema.json",
31
+ }
32
+
33
+
34
+ def schemas_dir() -> Path | None:
35
+ """Locate the local ``schemas/`` directory when available."""
36
+ here = Path(__file__).resolve()
37
+ candidates = [
38
+ # Packaged: caraer_cli/schemas (via hatch force-include)
39
+ here.parents[1] / "schemas",
40
+ # Dev checkout: <repo>/schemas
41
+ here.parents[2] / "schemas",
42
+ ]
43
+ for path in candidates:
44
+ if path.is_dir() and (path / "function.caraer.schema.json").is_file():
45
+ return path
46
+ return None
47
+
48
+
49
+ def with_json_schema(payload: dict[str, Any], schema_url: str) -> dict[str, Any]:
50
+ """Return a copy of payload with ``$schema`` first."""
51
+ out: dict[str, Any] = {"$schema": schema_url}
52
+ for key, value in payload.items():
53
+ if key == "$schema":
54
+ continue
55
+ out[key] = value
56
+ return out
57
+
58
+
59
+ def dump_json_with_schema(
60
+ path: Path, payload: dict[str, Any], schema_url: str
61
+ ) -> None:
62
+ """Write JSON with a leading ``$schema`` field."""
63
+ path.write_text(
64
+ json.dumps(with_json_schema(payload, schema_url), indent=2) + "\n",
65
+ encoding="utf-8",
66
+ )
@@ -0,0 +1,119 @@
1
+ """Discover / write lifecycle webhook configs under src/app/lifecycle/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from caraer_cli.project.paths import lifecycle_dir
10
+ from caraer_cli.project.schema import ProjectConfig
11
+
12
+ # filename stem → (manifest key, expected topic)
13
+ LIFECYCLE_HOOKS: dict[str, tuple[str, str]] = {
14
+ "install": ("installWebhook", "app.installed"),
15
+ "uninstall": ("uninstallWebhook", "app.uninstalled"),
16
+ "rotate": ("rotateWebhook", "app.rotated"),
17
+ "update": ("updateWebhook", "app.updated"),
18
+ }
19
+
20
+ MANIFEST_KEY_TO_FILE = {
21
+ manifest_key: stem for stem, (manifest_key, _) in LIFECYCLE_HOOKS.items()
22
+ }
23
+
24
+ LOCAL_LIFECYCLE_KEYS = (
25
+ "topic",
26
+ "deliveryMode",
27
+ "url",
28
+ "description",
29
+ "enabled",
30
+ "webhookFormat",
31
+ "secret",
32
+ "serverlessFunction",
33
+ )
34
+
35
+
36
+ def sanitize_lifecycle(item: dict[str, Any]) -> dict[str, Any]:
37
+ payload: dict[str, Any] = {}
38
+ for key in LOCAL_LIFECYCLE_KEYS:
39
+ if key in item and item[key] is not None:
40
+ payload[key] = item[key]
41
+ sf = payload.get("serverlessFunction")
42
+ if isinstance(sf, dict):
43
+ cleaned: dict[str, Any] = {}
44
+ if sf.get("name"):
45
+ cleaned["name"] = sf["name"]
46
+ elif sf.get("uuid"):
47
+ cleaned["uuid"] = sf["uuid"]
48
+ if cleaned:
49
+ payload["serverlessFunction"] = cleaned
50
+ else:
51
+ payload.pop("serverlessFunction", None)
52
+ return payload
53
+
54
+
55
+ def discover_local_lifecycle(
56
+ root: Path, config: ProjectConfig
57
+ ) -> dict[str, dict[str, Any]]:
58
+ """Return manifest_key → sanitized webhook dict for present lifecycle files."""
59
+ base = lifecycle_dir(root, config.srcDir)
60
+ if not base.is_dir():
61
+ return {}
62
+ found: dict[str, dict[str, Any]] = {}
63
+ for stem, (manifest_key, _topic) in LIFECYCLE_HOOKS.items():
64
+ path = base / f"{stem}.json"
65
+ if not path.is_file():
66
+ continue
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ if isinstance(data, dict):
69
+ found[manifest_key] = sanitize_lifecycle(data)
70
+ return found
71
+
72
+
73
+ def write_lifecycle_files(
74
+ root: Path, config: ProjectConfig, hooks: dict[str, dict[str, Any] | None]
75
+ ) -> int:
76
+ """Write lifecycle/*.json from manifest hook objects. Clears missing files."""
77
+ base = lifecycle_dir(root, config.srcDir)
78
+ base.mkdir(parents=True, exist_ok=True)
79
+ count = 0
80
+ for stem, (manifest_key, expected_topic) in LIFECYCLE_HOOKS.items():
81
+ path = base / f"{stem}.json"
82
+ raw = hooks.get(manifest_key)
83
+ if not isinstance(raw, dict) or not raw:
84
+ if path.exists():
85
+ path.unlink()
86
+ continue
87
+ sanitized = sanitize_lifecycle(raw)
88
+ if not sanitized.get("topic"):
89
+ sanitized["topic"] = expected_topic
90
+ path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
91
+ count += 1
92
+ return count
93
+
94
+
95
+ def resolve_lifecycle_functions(
96
+ webhook: dict[str, Any],
97
+ *,
98
+ fn_by_name: dict[str, str],
99
+ strict: bool = True,
100
+ ) -> dict[str, Any] | None:
101
+ out = dict(webhook)
102
+ sf = out.get("serverlessFunction")
103
+ if not isinstance(sf, dict):
104
+ return out
105
+ if sf.get("uuid"):
106
+ out["serverlessFunction"] = {"uuid": sf["uuid"]}
107
+ return out
108
+ name = sf.get("name")
109
+ if name and name in fn_by_name:
110
+ out["serverlessFunction"] = {"uuid": fn_by_name[name]}
111
+ return out
112
+ if name:
113
+ if strict:
114
+ raise ValueError(
115
+ f"Lifecycle webhook references function '{name}' but no UUID is known. "
116
+ "Push functions first."
117
+ )
118
+ return None
119
+ return out