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,51 @@
|
|
|
1
|
+
"""Append marketplace items into app.caraer.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from caraer_cli.local_app import load_local_app
|
|
9
|
+
from caraer_cli.project.paths import app_manifest_path
|
|
10
|
+
from caraer_cli.project.scaffold import write_app_manifest
|
|
11
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def append_manifest_list_item(
|
|
15
|
+
root: Path,
|
|
16
|
+
config: ProjectConfig,
|
|
17
|
+
*,
|
|
18
|
+
list_key: str,
|
|
19
|
+
item: dict[str, Any],
|
|
20
|
+
identity: Callable[[dict[str, Any]], str],
|
|
21
|
+
force: bool = False,
|
|
22
|
+
) -> Path:
|
|
23
|
+
"""Append (or replace) an item in a top-level list on ``app.caraer.yaml``."""
|
|
24
|
+
manifest_path = app_manifest_path(root, config.srcDir)
|
|
25
|
+
if not manifest_path.is_file():
|
|
26
|
+
raise FileNotFoundError(f"App manifest not found: {manifest_path}")
|
|
27
|
+
payload = load_local_app(manifest_path)
|
|
28
|
+
items = payload.get(list_key)
|
|
29
|
+
if not isinstance(items, list):
|
|
30
|
+
items = []
|
|
31
|
+
key = identity(item)
|
|
32
|
+
if not key:
|
|
33
|
+
raise ValueError(f"Cannot determine identity for {list_key} item.")
|
|
34
|
+
existing_idx = None
|
|
35
|
+
for idx, current in enumerate(items):
|
|
36
|
+
if isinstance(current, dict) and identity(current) == key:
|
|
37
|
+
existing_idx = idx
|
|
38
|
+
break
|
|
39
|
+
if existing_idx is not None and not force:
|
|
40
|
+
raise FileExistsError(
|
|
41
|
+
f"{list_key} already has an item matching '{key}'. Use --force to replace."
|
|
42
|
+
)
|
|
43
|
+
next_items = list(items)
|
|
44
|
+
if existing_idx is not None:
|
|
45
|
+
next_items[existing_idx] = item
|
|
46
|
+
else:
|
|
47
|
+
next_items.append(item)
|
|
48
|
+
payload[list_key] = next_items
|
|
49
|
+
return write_app_manifest(
|
|
50
|
+
root, payload, src_dir=config.srcDir, include_examples=False
|
|
51
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Assemble modular marketplace/lifecycle files into the app manifest payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from caraer_cli.project.app_bars_sync import (
|
|
9
|
+
app_bar_identity,
|
|
10
|
+
discover_local_app_bars,
|
|
11
|
+
resolve_app_bar_functions,
|
|
12
|
+
write_app_bars_files,
|
|
13
|
+
)
|
|
14
|
+
from caraer_cli.project.lifecycle_sync import (
|
|
15
|
+
LIFECYCLE_HOOKS,
|
|
16
|
+
discover_local_lifecycle,
|
|
17
|
+
resolve_lifecycle_functions,
|
|
18
|
+
write_lifecycle_files,
|
|
19
|
+
)
|
|
20
|
+
from caraer_cli.project.pricing_sync import (
|
|
21
|
+
discover_local_pricing,
|
|
22
|
+
pricing_identity,
|
|
23
|
+
write_pricing_files,
|
|
24
|
+
)
|
|
25
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
26
|
+
from caraer_cli.project.settings_sync import discover_local_settings, write_settings_files
|
|
27
|
+
from caraer_cli.project.state import load_state
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fn_by_name(root: Path) -> dict[str, str]:
|
|
31
|
+
state = load_state(root)
|
|
32
|
+
fn_state = state.get("functions") or {}
|
|
33
|
+
return {
|
|
34
|
+
name: str(meta["uuid"])
|
|
35
|
+
for name, meta in fn_state.items()
|
|
36
|
+
if isinstance(meta, dict) and meta.get("uuid")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _merge_by_key(
|
|
41
|
+
yaml_items: list[Any],
|
|
42
|
+
file_items: list[dict[str, Any]],
|
|
43
|
+
*,
|
|
44
|
+
identity,
|
|
45
|
+
) -> list[dict[str, Any]]:
|
|
46
|
+
"""Merge YAML list with file list; files win on identity conflict."""
|
|
47
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
48
|
+
order: list[str] = []
|
|
49
|
+
for item in yaml_items:
|
|
50
|
+
if not isinstance(item, dict):
|
|
51
|
+
continue
|
|
52
|
+
key = identity(item)
|
|
53
|
+
if not key:
|
|
54
|
+
continue
|
|
55
|
+
if key not in merged:
|
|
56
|
+
order.append(key)
|
|
57
|
+
merged[key] = item
|
|
58
|
+
for item in file_items:
|
|
59
|
+
key = identity(item)
|
|
60
|
+
if not key:
|
|
61
|
+
continue
|
|
62
|
+
if key not in merged:
|
|
63
|
+
order.append(key)
|
|
64
|
+
merged[key] = item
|
|
65
|
+
return [merged[k] for k in order]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def assemble_local_manifest(
|
|
69
|
+
root: Path,
|
|
70
|
+
config: ProjectConfig,
|
|
71
|
+
local: dict[str, Any],
|
|
72
|
+
*,
|
|
73
|
+
resolve_functions: bool = True,
|
|
74
|
+
strict_function_refs: bool = True,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""Return a copy of ``local`` with modular disk files merged in.
|
|
77
|
+
|
|
78
|
+
When ``resolve_functions`` is True, serverlessFunction name refs are replaced
|
|
79
|
+
with UUIDs from ``.caraer/state.json`` (required before push).
|
|
80
|
+
When ``strict_function_refs`` is False, hooks/bars that cannot resolve are omitted.
|
|
81
|
+
"""
|
|
82
|
+
out = dict(local)
|
|
83
|
+
fn_by_name = _fn_by_name(root) if resolve_functions else {}
|
|
84
|
+
|
|
85
|
+
yaml_settings = out.get("settingsSchema") if isinstance(out.get("settingsSchema"), list) else []
|
|
86
|
+
file_settings = [item for _path, item in discover_local_settings(root, config)]
|
|
87
|
+
out["settingsSchema"] = _merge_by_key(
|
|
88
|
+
yaml_settings,
|
|
89
|
+
file_settings,
|
|
90
|
+
identity=lambda i: str(i.get("name") or "").strip().lower(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
yaml_pricing = out.get("pricingPlans") if isinstance(out.get("pricingPlans"), list) else []
|
|
94
|
+
file_pricing = [item for _path, item in discover_local_pricing(root, config)]
|
|
95
|
+
out["pricingPlans"] = _merge_by_key(
|
|
96
|
+
yaml_pricing,
|
|
97
|
+
file_pricing,
|
|
98
|
+
identity=pricing_identity,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
yaml_bars = out.get("appBars") if isinstance(out.get("appBars"), list) else []
|
|
102
|
+
file_bars = [item for _path, item in discover_local_app_bars(root, config)]
|
|
103
|
+
merged_bars = _merge_by_key(yaml_bars, file_bars, identity=app_bar_identity)
|
|
104
|
+
if resolve_functions:
|
|
105
|
+
resolved_bars: list[dict[str, Any]] = []
|
|
106
|
+
for bar in merged_bars:
|
|
107
|
+
resolved = resolve_app_bar_functions(
|
|
108
|
+
bar, fn_by_name=fn_by_name, strict=strict_function_refs
|
|
109
|
+
)
|
|
110
|
+
if resolved is not None:
|
|
111
|
+
resolved_bars.append(resolved)
|
|
112
|
+
out["appBars"] = resolved_bars
|
|
113
|
+
else:
|
|
114
|
+
out["appBars"] = merged_bars
|
|
115
|
+
|
|
116
|
+
file_hooks = discover_local_lifecycle(root, config)
|
|
117
|
+
for _stem, (manifest_key, expected_topic) in LIFECYCLE_HOOKS.items():
|
|
118
|
+
hook = file_hooks.get(manifest_key)
|
|
119
|
+
if hook is None:
|
|
120
|
+
continue
|
|
121
|
+
payload = dict(hook)
|
|
122
|
+
if not payload.get("topic"):
|
|
123
|
+
payload["topic"] = expected_topic
|
|
124
|
+
if resolve_functions:
|
|
125
|
+
resolved = resolve_lifecycle_functions(
|
|
126
|
+
payload, fn_by_name=fn_by_name, strict=strict_function_refs
|
|
127
|
+
)
|
|
128
|
+
if resolved is None:
|
|
129
|
+
out.pop(manifest_key, None)
|
|
130
|
+
continue
|
|
131
|
+
payload = resolved
|
|
132
|
+
out[manifest_key] = payload
|
|
133
|
+
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def split_marketplace_to_disk(
|
|
138
|
+
root: Path,
|
|
139
|
+
config: ProjectConfig,
|
|
140
|
+
payload: dict[str, Any],
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Write modular files from a remote/local payload and clear arrays/hooks in YAML copy."""
|
|
143
|
+
out = dict(payload)
|
|
144
|
+
|
|
145
|
+
settings = out.get("settingsSchema") if isinstance(out.get("settingsSchema"), list) else []
|
|
146
|
+
write_settings_files(root, config, [i for i in settings if isinstance(i, dict)])
|
|
147
|
+
out["settingsSchema"] = []
|
|
148
|
+
|
|
149
|
+
pricing = out.get("pricingPlans") if isinstance(out.get("pricingPlans"), list) else []
|
|
150
|
+
write_pricing_files(root, config, [i for i in pricing if isinstance(i, dict)])
|
|
151
|
+
out["pricingPlans"] = []
|
|
152
|
+
|
|
153
|
+
bars = out.get("appBars") if isinstance(out.get("appBars"), list) else []
|
|
154
|
+
write_app_bars_files(root, config, [i for i in bars if isinstance(i, dict)])
|
|
155
|
+
out["appBars"] = []
|
|
156
|
+
|
|
157
|
+
hooks = {
|
|
158
|
+
key: out.get(key) if isinstance(out.get(key), dict) else None
|
|
159
|
+
for _stem, (key, _topic) in LIFECYCLE_HOOKS.items()
|
|
160
|
+
}
|
|
161
|
+
write_lifecycle_files(root, config, hooks)
|
|
162
|
+
for key in hooks:
|
|
163
|
+
out.pop(key, None)
|
|
164
|
+
|
|
165
|
+
return out
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Sync external OAuth providers from app.caraer.yaml to the remote API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from caraer_cli.api import integration_runtime as api
|
|
9
|
+
from caraer_cli.api.client import CaraerApiClient
|
|
10
|
+
from caraer_cli.local_app import load_local_app
|
|
11
|
+
from caraer_cli.project.paths import app_manifest_path
|
|
12
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
13
|
+
from caraer_cli.project.state import load_state, save_state
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _env_expand(value: Any) -> Any:
|
|
17
|
+
if not isinstance(value, str):
|
|
18
|
+
return value
|
|
19
|
+
text = value.strip()
|
|
20
|
+
if text.startswith("${") and text.endswith("}") and len(text) > 3:
|
|
21
|
+
return os.environ.get(text[2:-1], "")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _sanitize_provider(raw: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
out: dict[str, Any] = {}
|
|
27
|
+
for key in (
|
|
28
|
+
"uuid",
|
|
29
|
+
"name",
|
|
30
|
+
"logo",
|
|
31
|
+
"authorizeUrl",
|
|
32
|
+
"tokenUrl",
|
|
33
|
+
"clientId",
|
|
34
|
+
"clientSecret",
|
|
35
|
+
"scopes",
|
|
36
|
+
"pkce",
|
|
37
|
+
):
|
|
38
|
+
if key in raw and raw[key] is not None:
|
|
39
|
+
out[key] = _env_expand(raw[key])
|
|
40
|
+
# Ignore deprecated preset if present in older manifests.
|
|
41
|
+
if isinstance(out.get("scopes"), list):
|
|
42
|
+
out["scopes"] = [_env_expand(s) for s in out["scopes"]]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_local_providers(root, config: ProjectConfig) -> list[dict[str, Any]]:
|
|
47
|
+
manifest = load_local_app(app_manifest_path(root, config.srcDir))
|
|
48
|
+
providers = manifest.get("externalOAuthProviders") or []
|
|
49
|
+
if not isinstance(providers, list):
|
|
50
|
+
return []
|
|
51
|
+
return [_sanitize_provider(p) for p in providers if isinstance(p, dict) and p.get("name")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pull_external_oauth_providers(
|
|
55
|
+
client: CaraerApiClient,
|
|
56
|
+
root,
|
|
57
|
+
config: ProjectConfig,
|
|
58
|
+
) -> int:
|
|
59
|
+
"""Refresh externalOAuthProviders on the local app manifest from the remote API.
|
|
60
|
+
|
|
61
|
+
Secrets are never returned by the API; local clientSecret values are preserved
|
|
62
|
+
when the provider name/uuid still matches.
|
|
63
|
+
"""
|
|
64
|
+
if not config.appUuid:
|
|
65
|
+
raise ValueError("App is not linked.")
|
|
66
|
+
remote = api.list_external_oauth_providers(client, config.appUuid).get("data") or []
|
|
67
|
+
if not isinstance(remote, list):
|
|
68
|
+
remote = []
|
|
69
|
+
|
|
70
|
+
manifest_path = app_manifest_path(root, config.srcDir)
|
|
71
|
+
try:
|
|
72
|
+
manifest = load_local_app(manifest_path)
|
|
73
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
74
|
+
manifest = {}
|
|
75
|
+
existing = manifest.get("externalOAuthProviders") or []
|
|
76
|
+
existing_by_uuid: dict[str, dict[str, Any]] = {}
|
|
77
|
+
existing_by_name: dict[str, dict[str, Any]] = {}
|
|
78
|
+
if isinstance(existing, list):
|
|
79
|
+
for item in existing:
|
|
80
|
+
if not isinstance(item, dict):
|
|
81
|
+
continue
|
|
82
|
+
if item.get("uuid"):
|
|
83
|
+
existing_by_uuid[str(item["uuid"])] = item
|
|
84
|
+
if item.get("name"):
|
|
85
|
+
existing_by_name[str(item["name"])] = item
|
|
86
|
+
|
|
87
|
+
providers: list[dict[str, Any]] = []
|
|
88
|
+
providers_map: dict[str, str] = {}
|
|
89
|
+
for item in remote:
|
|
90
|
+
if not isinstance(item, dict) or not item.get("name"):
|
|
91
|
+
continue
|
|
92
|
+
sanitized = _sanitize_provider(item)
|
|
93
|
+
# API never returns clientSecret; keep local value when present.
|
|
94
|
+
prior = existing_by_uuid.get(str(item.get("uuid") or "")) or existing_by_name.get(
|
|
95
|
+
str(item.get("name") or "")
|
|
96
|
+
)
|
|
97
|
+
if prior and prior.get("clientSecret") and not sanitized.get("clientSecret"):
|
|
98
|
+
sanitized["clientSecret"] = prior["clientSecret"]
|
|
99
|
+
# Drop empty secret keys so YAML stays clean.
|
|
100
|
+
if not sanitized.get("clientSecret"):
|
|
101
|
+
sanitized.pop("clientSecret", None)
|
|
102
|
+
providers.append(sanitized)
|
|
103
|
+
if sanitized.get("uuid") and sanitized.get("name"):
|
|
104
|
+
providers_map[str(sanitized["name"])] = str(sanitized["uuid"])
|
|
105
|
+
|
|
106
|
+
from caraer_cli.project.app_manifest_template import render_app_manifest
|
|
107
|
+
|
|
108
|
+
manifest["externalOAuthProviders"] = providers
|
|
109
|
+
manifest_path.write_text(
|
|
110
|
+
render_app_manifest(manifest, include_examples=False),
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|
|
113
|
+
state = load_state(root)
|
|
114
|
+
state["externalOAuthProviders"] = providers_map
|
|
115
|
+
save_state(root, state)
|
|
116
|
+
return len(providers)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def push_external_oauth_providers(
|
|
120
|
+
client: CaraerApiClient,
|
|
121
|
+
root,
|
|
122
|
+
config: ProjectConfig,
|
|
123
|
+
*,
|
|
124
|
+
delete_missing: bool = False,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
if not config.appUuid:
|
|
127
|
+
raise ValueError("App is not linked.")
|
|
128
|
+
local = discover_local_providers(root, config)
|
|
129
|
+
if not local:
|
|
130
|
+
return {"pushed": 0, "skipped": True}
|
|
131
|
+
|
|
132
|
+
remote = api.list_external_oauth_providers(client, config.appUuid).get("data") or []
|
|
133
|
+
remote_by_name = {
|
|
134
|
+
str(i["name"]): i for i in remote if isinstance(i, dict) and i.get("name")
|
|
135
|
+
}
|
|
136
|
+
remote_by_uuid = {
|
|
137
|
+
str(i["uuid"]): i for i in remote if isinstance(i, dict) and i.get("uuid")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pushed: dict[str, str] = {}
|
|
141
|
+
kept_uuids: set[str] = set()
|
|
142
|
+
for provider in local:
|
|
143
|
+
name = str(provider.get("name") or "")
|
|
144
|
+
client_id = str(provider.get("clientId") or "").strip()
|
|
145
|
+
if not client_id:
|
|
146
|
+
# Placeholder providers are kept locally until Google credentials are set.
|
|
147
|
+
continue
|
|
148
|
+
uuid = str(provider.get("uuid") or "")
|
|
149
|
+
existing = remote_by_uuid.get(uuid) or remote_by_name.get(name)
|
|
150
|
+
payload = {k: v for k, v in provider.items() if k != "uuid"}
|
|
151
|
+
# Don't blank an existing secret when local secret omitted.
|
|
152
|
+
if existing and not payload.get("clientSecret"):
|
|
153
|
+
payload.pop("clientSecret", None)
|
|
154
|
+
if existing and existing.get("uuid"):
|
|
155
|
+
resp = api.update_external_oauth_provider(
|
|
156
|
+
client, config.appUuid, str(existing["uuid"]), payload
|
|
157
|
+
)
|
|
158
|
+
data = resp.get("data") or existing
|
|
159
|
+
else:
|
|
160
|
+
if not payload.get("clientSecret"):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"externalOAuthProviders.{name} requires clientSecret on first push "
|
|
163
|
+
"(set it in app.caraer.yaml or ${GMAIL_OAUTH_CLIENT_SECRET})."
|
|
164
|
+
)
|
|
165
|
+
resp = api.create_external_oauth_provider(client, config.appUuid, payload)
|
|
166
|
+
data = resp.get("data") or {}
|
|
167
|
+
if isinstance(data, dict) and data.get("uuid"):
|
|
168
|
+
kept_uuids.add(str(data["uuid"]))
|
|
169
|
+
if data.get("name"):
|
|
170
|
+
pushed[str(data["name"])] = str(data["uuid"])
|
|
171
|
+
|
|
172
|
+
if delete_missing:
|
|
173
|
+
for uuid in remote_by_uuid:
|
|
174
|
+
if uuid not in kept_uuids:
|
|
175
|
+
api.delete_external_oauth_provider(client, config.appUuid, uuid)
|
|
176
|
+
|
|
177
|
+
state = load_state(root)
|
|
178
|
+
state["externalOAuthProviders"] = pushed
|
|
179
|
+
save_state(root, state)
|
|
180
|
+
return {"pushed": len(pushed), "deletedMissing": delete_missing}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
WORKSPACE_FILE = "caraer.json"
|
|
7
|
+
LEGACY_PROJECT_FILE = "caraer.project.json"
|
|
8
|
+
# Back-compat alias used by older call sites.
|
|
9
|
+
PROJECT_FILE = WORKSPACE_FILE
|
|
10
|
+
STATE_DIR = ".caraer"
|
|
11
|
+
STATE_FILE = "state.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
15
|
+
"""Locate the app workspace root (caraer.json or legacy caraer.project.json)."""
|
|
16
|
+
current = (start or Path.cwd()).resolve()
|
|
17
|
+
for candidate in [current, *current.parents]:
|
|
18
|
+
if (candidate / WORKSPACE_FILE).is_file() or (
|
|
19
|
+
candidate / LEGACY_PROJECT_FILE
|
|
20
|
+
).is_file():
|
|
21
|
+
return candidate
|
|
22
|
+
raise FileNotFoundError(
|
|
23
|
+
f"No {WORKSPACE_FILE} found. Run 'caraer apps init' in your app directory."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def workspace_file(root: Path) -> Path:
|
|
28
|
+
"""Preferred workspace config path (always caraer.json)."""
|
|
29
|
+
return root / WORKSPACE_FILE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def project_file(root: Path) -> Path:
|
|
33
|
+
"""Return existing workspace file path (prefer caraer.json, else legacy)."""
|
|
34
|
+
modern = root / WORKSPACE_FILE
|
|
35
|
+
if modern.is_file():
|
|
36
|
+
return modern
|
|
37
|
+
legacy = root / LEGACY_PROJECT_FILE
|
|
38
|
+
if legacy.is_file():
|
|
39
|
+
return legacy
|
|
40
|
+
return modern
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def has_workspace_file(root: Path) -> bool:
|
|
44
|
+
return (root / WORKSPACE_FILE).is_file() or (
|
|
45
|
+
root / LEGACY_PROJECT_FILE
|
|
46
|
+
).is_file()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def state_path(root: Path) -> Path:
|
|
50
|
+
return root / STATE_DIR / STATE_FILE
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def app_dir(root: Path, src_dir: str = "src") -> Path:
|
|
54
|
+
return root / src_dir / "app"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def functions_dir(root: Path, src_dir: str = "src") -> Path:
|
|
58
|
+
return app_dir(root, src_dir) / "functions"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def webhooks_dir(root: Path, src_dir: str = "src") -> Path:
|
|
62
|
+
return app_dir(root, src_dir) / "webhooks"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def schedules_dir(root: Path, src_dir: str = "src") -> Path:
|
|
66
|
+
return app_dir(root, src_dir) / "schedules"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def inbound_dir(root: Path, src_dir: str = "src") -> Path:
|
|
70
|
+
return app_dir(root, src_dir) / "inbound"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def settings_dir(root: Path, src_dir: str = "src") -> Path:
|
|
74
|
+
return app_dir(root, src_dir) / "settings"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pricing_dir(root: Path, src_dir: str = "src") -> Path:
|
|
78
|
+
return app_dir(root, src_dir) / "pricing"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def app_bars_dir(root: Path, src_dir: str = "src") -> Path:
|
|
82
|
+
return app_dir(root, src_dir) / "app-bars"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def lifecycle_dir(root: Path, src_dir: str = "src") -> Path:
|
|
86
|
+
return app_dir(root, src_dir) / "lifecycle"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
APP_MANIFEST_YAML = "app.caraer.yaml"
|
|
90
|
+
APP_MANIFEST_JSON = "app.caraer.json"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def app_manifest_path(root: Path, src_dir: str = "src") -> Path:
|
|
94
|
+
"""Preferred local app definition path (YAML). Falls back to legacy JSON if present."""
|
|
95
|
+
base = app_dir(root, src_dir)
|
|
96
|
+
yaml_path = base / APP_MANIFEST_YAML
|
|
97
|
+
json_path = base / APP_MANIFEST_JSON
|
|
98
|
+
if yaml_path.is_file():
|
|
99
|
+
return yaml_path
|
|
100
|
+
if json_path.is_file():
|
|
101
|
+
return json_path
|
|
102
|
+
return yaml_path
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Discover / write modular pricingPlans files under src/app/pricing/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from caraer_cli.project.paths import pricing_dir
|
|
11
|
+
from caraer_cli.project.schema import ProjectConfig
|
|
12
|
+
|
|
13
|
+
LOCAL_PRICING_KEYS = (
|
|
14
|
+
"uuid",
|
|
15
|
+
"name",
|
|
16
|
+
"title",
|
|
17
|
+
"description",
|
|
18
|
+
"pricingType",
|
|
19
|
+
"pricePerUnit",
|
|
20
|
+
"unit",
|
|
21
|
+
"freeUnits",
|
|
22
|
+
"freeUnitsPeriod",
|
|
23
|
+
"tiers",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _slug(value: str) -> str:
|
|
28
|
+
text = value.strip().lower()
|
|
29
|
+
text = re.sub(r"[^a-z0-9]+", "-", text)
|
|
30
|
+
return text.strip("-") or "plan"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pricing_filename(item: dict[str, Any]) -> str:
|
|
34
|
+
key = str(item.get("name") or item.get("title") or "plan")
|
|
35
|
+
return f"{_slug(key)}.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sanitize_pricing(item: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
payload: dict[str, Any] = {}
|
|
40
|
+
for key in LOCAL_PRICING_KEYS:
|
|
41
|
+
if key in item and item[key] is not None:
|
|
42
|
+
payload[key] = item[key]
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pricing_identity(item: dict[str, Any]) -> str:
|
|
47
|
+
return str(item.get("name") or item.get("title") or "").strip().lower()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discover_local_pricing(
|
|
51
|
+
root: Path, config: ProjectConfig
|
|
52
|
+
) -> list[tuple[Path, dict[str, Any]]]:
|
|
53
|
+
base = pricing_dir(root, config.srcDir)
|
|
54
|
+
if not base.is_dir():
|
|
55
|
+
return []
|
|
56
|
+
found: list[tuple[Path, dict[str, Any]]] = []
|
|
57
|
+
for path in sorted(base.glob("*.json")):
|
|
58
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
if isinstance(data, dict) and (data.get("title") or data.get("name")):
|
|
60
|
+
found.append((path, sanitize_pricing(data)))
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_pricing_files(
|
|
65
|
+
root: Path, config: ProjectConfig, items: list[dict[str, Any]]
|
|
66
|
+
) -> int:
|
|
67
|
+
base = pricing_dir(root, config.srcDir)
|
|
68
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
for existing in base.glob("*.json"):
|
|
70
|
+
existing.unlink()
|
|
71
|
+
count = 0
|
|
72
|
+
for item in items:
|
|
73
|
+
if not isinstance(item, dict):
|
|
74
|
+
continue
|
|
75
|
+
sanitized = sanitize_pricing(item)
|
|
76
|
+
if not pricing_identity(sanitized):
|
|
77
|
+
continue
|
|
78
|
+
path = base / pricing_filename(sanitized)
|
|
79
|
+
path.write_text(json.dumps(sanitized, indent=2) + "\n", encoding="utf-8")
|
|
80
|
+
count += 1
|
|
81
|
+
return count
|