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