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,519 @@
1
+ """Interactive public-app JSON wizard."""
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 questionary import Choice
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from caraer_cli.wizard.catalog import (
16
+ MAIN_CATEGORIES,
17
+ find_main_category,
18
+ )
19
+ from caraer_cli.wizard.marketplace import (
20
+ prompt_app_bar as _prompt_app_bar,
21
+ prompt_pricing_plan as _prompt_pricing_plan,
22
+ prompt_setting_field as _prompt_setting_field,
23
+ )
24
+ from caraer_cli.wizard.prompts import (
25
+ WizardCancelled,
26
+ ask_checkbox,
27
+ ask_confirm,
28
+ ask_select,
29
+ ask_text,
30
+ )
31
+ from caraer_cli.wizard.scope_macros import (
32
+ COMMON_OBJECT_NAMES,
33
+ EXACT_SCOPE_EXAMPLES,
34
+ KNOWN_TOOL_NAMES,
35
+ SCOPE_MACRO_PRESETS,
36
+ find_preset,
37
+ materialize_macro,
38
+ )
39
+
40
+ console = Console()
41
+
42
+ DEFAULT_BRAND_COLOR = "#E74363"
43
+ DEFAULT_TEXT_COLOR = "#FFFFFF"
44
+ DEFAULT_APP_FILE = "app.caraer.yaml"
45
+
46
+
47
+ def normalize_app_name(value: str) -> str:
48
+ normalized = value.strip().lower()
49
+ normalized = re.sub(r"[^a-z0-9]+", "_", normalized)
50
+ normalized = re.sub(r"_+", "_", normalized).strip("_")
51
+ return normalized
52
+
53
+
54
+ def _print_section(title: str) -> None:
55
+ console.print()
56
+ console.print(Panel(title, expand=False, border_style="magenta"))
57
+
58
+
59
+ def _print_overview(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
60
+ console.print()
61
+ if not rows:
62
+ console.print(f"[dim]{title}: (none yet)[/dim]")
63
+ return
64
+ table = Table(title=title, show_header=True, header_style="bold")
65
+ for column in columns:
66
+ table.add_column(column)
67
+ for index, row in enumerate(rows, start=1):
68
+ values = [str(index)]
69
+ for column in columns[1:]:
70
+ value = row.get(column, "")
71
+ if isinstance(value, list):
72
+ value = ", ".join(str(item) for item in value)
73
+ values.append("" if value is None else str(value))
74
+ table.add_row(*values)
75
+ console.print(table)
76
+
77
+
78
+ def _optional(value: str | None) -> str | None:
79
+ text = (value or "").strip()
80
+ return text or None
81
+
82
+
83
+ def _prompt_core() -> tuple[str, str]:
84
+ _print_section("Core")
85
+ label = ask_text("Label", required=True)
86
+ default_name = normalize_app_name(label) or "my_app"
87
+ name_raw = ask_text("Name (Enter to keep default)", default=default_name, required=True)
88
+ name = normalize_app_name(name_raw) or default_name
89
+ if name != name_raw.strip():
90
+ console.print(f"[dim]Normalized name to[/dim] [bold]{name}[/bold]")
91
+ return label, name
92
+
93
+
94
+ def _prompt_details() -> dict[str, Any]:
95
+ _print_section("Marketplace details")
96
+ title = ask_text("Title", default="")
97
+ description = ask_text("Description", default="")
98
+
99
+ category_key = ask_select(
100
+ "Main category",
101
+ [Choice(title=f"{c.label}", value=c.key) for c in MAIN_CATEGORIES],
102
+ default=MAIN_CATEGORIES[0].key,
103
+ )
104
+ category = find_main_category(category_key)
105
+ assert category is not None
106
+
107
+ subcategory_keys = ask_checkbox(
108
+ "Subcategories (space to toggle, enter to confirm)",
109
+ [Choice(title=s.label, value=s.key) for s in category.subcategories],
110
+ min_selected=1,
111
+ )
112
+
113
+ url = ask_text("Details URL (optional)", default="")
114
+ image = ask_text("Image URL (optional)", default="")
115
+ brand_color = ask_text("Brand color (#RRGGBB)", default=DEFAULT_BRAND_COLOR)
116
+ text_color = ask_text("Text color (#RRGGBB)", default=DEFAULT_TEXT_COLOR)
117
+
118
+ details: dict[str, Any] = {
119
+ "title": title or None,
120
+ "description": description or None,
121
+ "category": category_key,
122
+ "subcategories": subcategory_keys,
123
+ "brandColor": brand_color or DEFAULT_BRAND_COLOR,
124
+ "textColor": text_color or DEFAULT_TEXT_COLOR,
125
+ }
126
+ if _optional(url):
127
+ details["url"] = url.strip()
128
+ if _optional(image):
129
+ details["image"] = image.strip()
130
+ return {key: value for key, value in details.items() if value is not None}
131
+
132
+
133
+ def _print_scope_overview(scopes: list[str]) -> None:
134
+ rows = [{"scope": scope, "kind": _scope_kind(scope)} for scope in scopes]
135
+ _print_overview("Required scopes", rows, ["#", "scope", "kind"])
136
+
137
+
138
+ def _scope_kind(scope: str) -> str:
139
+ parts = scope.split(".")
140
+ if scope == "global.all":
141
+ return "macro"
142
+ if len(parts) == 3 and parts[0] == "tools" and parts[2] == "all":
143
+ return "macro"
144
+ if len(parts) == 3 and parts[0] == "records" and parts[2] in {
145
+ "all",
146
+ "properties_all",
147
+ "relations_all",
148
+ }:
149
+ return "macro"
150
+ return "exact"
151
+
152
+
153
+ def _print_macro_help(preset_id: str) -> None:
154
+ preset = find_preset(preset_id)
155
+ if preset is None:
156
+ return
157
+ console.print(
158
+ Panel(
159
+ f"[bold]{preset.title}[/bold]\n"
160
+ f"Pattern: [cyan]{preset.pattern}[/cyan]\n"
161
+ f"Example: [cyan]{preset.example}[/cyan]\n\n"
162
+ f"{preset.description}\n\n"
163
+ f"[dim]When to use:[/dim] {preset.when_to_use}",
164
+ title="Macro help",
165
+ border_style="cyan",
166
+ expand=False,
167
+ )
168
+ )
169
+
170
+
171
+ def _ask_tool_name() -> str:
172
+ choice = ask_select(
173
+ "Tool name",
174
+ [
175
+ *[Choice(title=name, value=name) for name in KNOWN_TOOL_NAMES],
176
+ Choice(title="Other (type custom tool name)…", value="__custom__"),
177
+ ],
178
+ default="forms",
179
+ )
180
+ if choice == "__custom__":
181
+ return ask_text("Custom tool name (e.g. forms)", required=True)
182
+ return choice
183
+
184
+
185
+ def _ask_object_name() -> str:
186
+ choice = ask_select(
187
+ "Object name",
188
+ [
189
+ *[Choice(title=name, value=name) for name in COMMON_OBJECT_NAMES],
190
+ Choice(title="Other (type custom object name)…", value="__custom__"),
191
+ ],
192
+ default="candidate",
193
+ )
194
+ if choice == "__custom__":
195
+ return ask_text("Custom object name (e.g. candidate)", required=True)
196
+ return choice
197
+
198
+
199
+ def _add_unique(scopes: list[str], items: list[str]) -> list[str]:
200
+ existing = set(scopes)
201
+ for item in items:
202
+ normalized = item.strip()
203
+ if normalized and normalized not in existing:
204
+ scopes.append(normalized)
205
+ existing.add(normalized)
206
+ return scopes
207
+
208
+
209
+ def _prompt_add_macro(scopes: list[str]) -> list[str]:
210
+ preset_id = ask_select(
211
+ "Choose a scope macro",
212
+ [
213
+ Choice(
214
+ title=f"{preset.title} ({preset.pattern})",
215
+ value=preset.id,
216
+ )
217
+ for preset in SCOPE_MACRO_PRESETS
218
+ ],
219
+ )
220
+ _print_macro_help(preset_id)
221
+ preset = find_preset(preset_id)
222
+ assert preset is not None
223
+
224
+ tool: str | None = None
225
+ object_name: str | None = None
226
+ if preset.needs_tool:
227
+ tool = _ask_tool_name()
228
+ if preset.needs_object:
229
+ object_name = _ask_object_name()
230
+
231
+ added = materialize_macro(preset, tool=tool, object_name=object_name)
232
+ console.print(f"[dim]Adding:[/dim] {', '.join(added)}")
233
+ return _add_unique(scopes, added)
234
+
235
+
236
+ def _prompt_add_exact_scopes(scopes: list[str]) -> list[str]:
237
+ mode = ask_select(
238
+ "How do you want to add exact scopes?",
239
+ [
240
+ Choice(title="Pick from common examples", value="pick"),
241
+ Choice(title="Type one scope string", value="type"),
242
+ ],
243
+ default="pick",
244
+ )
245
+ if mode == "pick":
246
+ selected = ask_checkbox(
247
+ "Exact scopes (space to toggle)",
248
+ [Choice(title=scope, value=scope) for scope in EXACT_SCOPE_EXAMPLES],
249
+ )
250
+ return _add_unique(scopes, selected)
251
+
252
+ while True:
253
+ scope = ask_text(
254
+ "Exact scope (e.g. tools.forms.read or records.contact.property.email.read)",
255
+ required=True,
256
+ )
257
+ scopes = _add_unique(scopes, [scope])
258
+ if not ask_confirm("Add another exact scope?", default=False):
259
+ break
260
+ return scopes
261
+
262
+
263
+ def _prompt_scopes() -> list[str]:
264
+ _print_section("Scopes")
265
+ console.print(
266
+ "[dim]Prefer macros (e.g. tools.forms.all, records.candidate.properties_all) "
267
+ "over long exact lists. Macros expand per tenant at install time.[/dim]"
268
+ )
269
+ if not ask_confirm("Configure required scopes?", default=True):
270
+ return []
271
+
272
+ scopes: list[str] = []
273
+ while True:
274
+ if scopes:
275
+ _print_scope_overview(scopes)
276
+ action = ask_select(
277
+ "Scopes action",
278
+ [
279
+ Choice(title="Add a macro (recommended)", value="macro"),
280
+ Choice(title="Add exact scope(s)", value="exact"),
281
+ Choice(title="Remove a scope", value="remove"),
282
+ Choice(title="Done with scopes", value="done"),
283
+ ],
284
+ default="macro" if not scopes else "done",
285
+ )
286
+ if action == "done":
287
+ break
288
+ if action == "macro":
289
+ scopes = _prompt_add_macro(scopes)
290
+ continue
291
+ if action == "exact":
292
+ scopes = _prompt_add_exact_scopes(scopes)
293
+ continue
294
+ if action == "remove":
295
+ if not scopes:
296
+ console.print("[yellow]No scopes to remove.[/yellow]")
297
+ continue
298
+ to_remove = ask_checkbox(
299
+ "Select scopes to remove",
300
+ [Choice(title=scope, value=scope) for scope in scopes],
301
+ min_selected=1,
302
+ )
303
+ scopes = [scope for scope in scopes if scope not in set(to_remove)]
304
+
305
+ if scopes:
306
+ _print_scope_overview(scopes)
307
+ return scopes
308
+
309
+
310
+ def _prompt_settings_schema() -> list[dict[str, Any]]:
311
+ _print_section("Settings schema")
312
+ fields: list[dict[str, Any]] = []
313
+ while ask_confirm("Add a settings field?", default=False):
314
+ fields.append(_prompt_setting_field())
315
+ _print_overview(
316
+ "Settings schema",
317
+ fields,
318
+ ["#", "name", "label", "type", "required", "defaultValue"],
319
+ )
320
+ return fields
321
+
322
+
323
+ def _prompt_pricing_plans() -> list[dict[str, Any]]:
324
+ _print_section("Pricing plans")
325
+ plans: list[dict[str, Any]] = []
326
+ while ask_confirm("Add a pricing plan?", default=False):
327
+ plans.append(_prompt_pricing_plan())
328
+ _print_overview(
329
+ "Pricing plans",
330
+ plans,
331
+ ["#", "title", "pricingType", "pricePerUnit", "unit", "freeUnits"],
332
+ )
333
+ return plans
334
+
335
+
336
+ def _prompt_app_bars() -> list[dict[str, Any]]:
337
+ _print_section("App bars")
338
+ bars: list[dict[str, Any]] = []
339
+ while ask_confirm("Add an app bar?", default=False):
340
+ bars.append(_prompt_app_bar())
341
+ _print_overview(
342
+ "App bars",
343
+ bars,
344
+ ["#", "label", "location", "iframeUrl", "tooltipLabel"],
345
+ )
346
+ return bars
347
+
348
+
349
+ def _prompt_webhook_controls() -> dict[str, Any]:
350
+ _print_section("Webhook controls")
351
+ controls: dict[str, Any] = {}
352
+ rate = ask_text("Webhook rate limit per minute (optional)", default="100")
353
+ if _optional(rate):
354
+ try:
355
+ controls["webhookRateLimitPerMinute"] = int(rate.strip())
356
+ except ValueError:
357
+ console.print("[yellow]Ignored invalid rate limit; leaving unset.[/yellow]")
358
+ bill_failed = ask_select(
359
+ "Bill failed webhook requests?",
360
+ [
361
+ Choice(title="No", value="false"),
362
+ Choice(title="Yes", value="true"),
363
+ Choice(title="Skip / leave unset", value=""),
364
+ ],
365
+ default="false",
366
+ )
367
+ if bill_failed:
368
+ controls["billFailedWebhookRequests"] = bill_failed == "true"
369
+ return controls
370
+
371
+
372
+ def build_public_app_payload_from_answers(
373
+ *,
374
+ label: str,
375
+ name: str,
376
+ details: dict[str, Any],
377
+ required_scopes: list[str],
378
+ settings_schema: list[dict[str, Any]],
379
+ pricing_plans: list[dict[str, Any]],
380
+ app_bars: list[dict[str, Any]],
381
+ webhook_controls: dict[str, Any],
382
+ runtime: str = "nodejs22",
383
+ auth_method: str = "OAUTH2",
384
+ oauth_redirect_uris: list[str] | None = None,
385
+ ) -> dict[str, Any]:
386
+ from caraer_cli.commands.apps import DEFAULT_OAUTH_CALLBACK
387
+
388
+ method = (auth_method or "OAUTH2").strip().upper()
389
+ redirects = [
390
+ uri.strip()
391
+ for uri in (oauth_redirect_uris or [DEFAULT_OAUTH_CALLBACK])
392
+ if uri and uri.strip()
393
+ ]
394
+ payload: dict[str, Any] = {
395
+ "label": label,
396
+ "name": name,
397
+ "runtime": runtime,
398
+ "authMethod": method,
399
+ "oauthRedirectUris": redirects,
400
+ "details": details,
401
+ "requiredScopes": required_scopes,
402
+ "settingsSchema": settings_schema,
403
+ "pricingPlans": pricing_plans,
404
+ "appBars": app_bars,
405
+ }
406
+ payload.update(webhook_controls)
407
+ return payload
408
+
409
+
410
+ def run_public_app_wizard(
411
+ *,
412
+ output_dir: str | Path | None = None,
413
+ force: bool = False,
414
+ sample_function: str | None = "hello-world",
415
+ runtime: str = "nodejs22",
416
+ ) -> Path:
417
+ """Run the interactive public-app wizard and scaffold a local app folder.
418
+
419
+ Returns the path to ``src/app/app.caraer.yaml`` inside the new app folder.
420
+ """
421
+ from caraer_cli.project.scaffold import scaffold_app_project
422
+
423
+ console.print(Panel.fit("Create Public App Wizard", border_style="magenta"))
424
+
425
+ from caraer_cli.commands.apps import DEFAULT_OAUTH_CALLBACK
426
+
427
+ label, name = _prompt_core()
428
+ details = _prompt_details()
429
+ required_scopes = _prompt_scopes()
430
+ settings_schema = _prompt_settings_schema()
431
+ pricing_plans = _prompt_pricing_plans()
432
+ app_bars = _prompt_app_bars()
433
+ webhook_controls = _prompt_webhook_controls()
434
+
435
+ auth_method = ask_select(
436
+ "Auth method",
437
+ [
438
+ Choice(title="OAuth 2.0", value="OAUTH2"),
439
+ Choice(title="API key", value="API_KEY"),
440
+ ],
441
+ default="OAUTH2",
442
+ )
443
+ oauth_redirect_uris = [
444
+ ask_text(
445
+ "OAuth callback / redirect URL",
446
+ default=DEFAULT_OAUTH_CALLBACK,
447
+ required=True,
448
+ )
449
+ ]
450
+
451
+ _print_section("App")
452
+ console.print(
453
+ f"- label: [bold]{label}[/bold]\n"
454
+ f"- name: [bold]{name}[/bold]\n"
455
+ f"- settings: {len(settings_schema)}\n"
456
+ f"- pricingPlans: {len(pricing_plans)}\n"
457
+ f"- appBars: {len(app_bars)}"
458
+ )
459
+ console.print(
460
+ "[dim]App definition goes in src/app/app.caraer.yaml. "
461
+ "Functions live under src/app/functions/; webhooks under src/app/webhooks/.[/dim]"
462
+ )
463
+
464
+ default_dir = str(output_dir or name)
465
+ project_dir = Path(ask_text("App directory", default=default_dir, required=True))
466
+ runtime = ask_select(
467
+ "App runtime",
468
+ [
469
+ Choice(title="Node.js 22", value="nodejs22"),
470
+ Choice(title="Python 3.12", value="python312"),
471
+ Choice(title="Skip sample function", value=""),
472
+ ],
473
+ default=runtime if sample_function else "",
474
+ )
475
+ function_name = sample_function if runtime else None
476
+ if runtime and sample_function:
477
+ function_name = ask_text("Sample function name", default=sample_function, required=True)
478
+
479
+ resolved_runtime = runtime or "nodejs22"
480
+ payload = build_public_app_payload_from_answers(
481
+ label=label,
482
+ name=name,
483
+ details=details,
484
+ required_scopes=required_scopes,
485
+ settings_schema=settings_schema,
486
+ pricing_plans=pricing_plans,
487
+ app_bars=app_bars,
488
+ webhook_controls=webhook_controls,
489
+ runtime=resolved_runtime,
490
+ auth_method=auth_method,
491
+ oauth_redirect_uris=oauth_redirect_uris,
492
+ )
493
+
494
+ if project_dir.exists() and any(project_dir.iterdir()) and not force:
495
+ if not ask_confirm(
496
+ f"{project_dir} already exists and is not empty. Continue / overwrite app files?",
497
+ default=False,
498
+ ):
499
+ raise WizardCancelled()
500
+
501
+ result = scaffold_app_project(
502
+ project_dir,
503
+ app_payload=payload,
504
+ project_name=name,
505
+ sample_function=function_name,
506
+ runtime=resolved_runtime,
507
+ force=True,
508
+ )
509
+ app_file: Path = result["app_file"]
510
+ console.print()
511
+ console.print(f"[green]Created app at {result['root']}[/green]")
512
+ console.print(f"[dim]App definition:[/dim] {app_file}")
513
+ console.print(f"[dim]Functions:[/dim] {result['functions_dir']}")
514
+ console.print(f"[dim]Webhooks:[/dim] {result['webhooks_dir']}")
515
+ console.print(
516
+ "[dim]Next:[/dim] cd "
517
+ f"{result['root'].name} && caraer apps push"
518
+ )
519
+ return app_file