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,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import time
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from caraer_cli.api import auth as auth_api
|
|
10
|
+
from caraer_cli.context import AppContext
|
|
11
|
+
from caraer_cli.errors import ApiError, AuthError
|
|
12
|
+
from caraer_cli.formatters.output import print_data, print_success, print_warning
|
|
13
|
+
from caraer_cli.state import session as session_store
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Authentication commands.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _store_tokens(profile: str, data: dict) -> str:
|
|
19
|
+
"""Extract and persist access (+ optional refresh) token from an auth payload."""
|
|
20
|
+
token = None
|
|
21
|
+
refresh_token = data.get("refreshToken")
|
|
22
|
+
token_obj = data.get("token")
|
|
23
|
+
if isinstance(token_obj, dict):
|
|
24
|
+
token = token_obj.get("token")
|
|
25
|
+
if not token:
|
|
26
|
+
token = data.get("accessToken")
|
|
27
|
+
if not token:
|
|
28
|
+
raise typer.BadParameter("Login succeeded but no token was returned.")
|
|
29
|
+
session_store.set_session_token(
|
|
30
|
+
profile,
|
|
31
|
+
str(token),
|
|
32
|
+
refresh_token=str(refresh_token) if refresh_token else None,
|
|
33
|
+
)
|
|
34
|
+
return str(token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("login")
|
|
38
|
+
def login(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
email: str | None = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--email",
|
|
43
|
+
help="Account email (password login). Omit for device-code browser login.",
|
|
44
|
+
),
|
|
45
|
+
password: str = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--password",
|
|
48
|
+
prompt=False,
|
|
49
|
+
hide_input=True,
|
|
50
|
+
help="Password (prompted securely if --email is set and password omitted).",
|
|
51
|
+
),
|
|
52
|
+
device: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--device",
|
|
55
|
+
help="Force device-code login (default when --email is omitted).",
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Log in and store a session token for the active profile.
|
|
59
|
+
|
|
60
|
+
Defaults to device-code / browser login. Pass --email for password login (CI).
|
|
61
|
+
"""
|
|
62
|
+
app_ctx: AppContext = ctx.obj
|
|
63
|
+
client = app_ctx.api_client()
|
|
64
|
+
|
|
65
|
+
use_device = device or not email
|
|
66
|
+
if use_device and email:
|
|
67
|
+
print_warning("Ignoring --email/--password; using device-code login.")
|
|
68
|
+
|
|
69
|
+
if use_device:
|
|
70
|
+
try:
|
|
71
|
+
start = auth_api.device_start(client).get("data") or {}
|
|
72
|
+
except ApiError as exc:
|
|
73
|
+
if email:
|
|
74
|
+
print_warning(
|
|
75
|
+
f"Device login unavailable ({exc}). Falling back to password login."
|
|
76
|
+
)
|
|
77
|
+
use_device = False
|
|
78
|
+
else:
|
|
79
|
+
raise typer.BadParameter(
|
|
80
|
+
"Device login is unavailable on this backend. "
|
|
81
|
+
"Use 'caraer auth login --email you@example.com'."
|
|
82
|
+
) from exc
|
|
83
|
+
else:
|
|
84
|
+
device_code = str(start.get("deviceCode") or "")
|
|
85
|
+
user_code = str(start.get("userCode") or "")
|
|
86
|
+
verification_uri = str(
|
|
87
|
+
start.get("verificationUriComplete")
|
|
88
|
+
or start.get("verificationUri")
|
|
89
|
+
or ""
|
|
90
|
+
)
|
|
91
|
+
interval = float(start.get("interval") or 5)
|
|
92
|
+
expires_in = float(start.get("expiresIn") or 600)
|
|
93
|
+
if not device_code or not user_code:
|
|
94
|
+
raise typer.BadParameter("Device start response missing codes.")
|
|
95
|
+
print_success(f"Device code: {user_code}")
|
|
96
|
+
if verification_uri:
|
|
97
|
+
print_success(f"Open: {verification_uri}")
|
|
98
|
+
try:
|
|
99
|
+
webbrowser.open(verification_uri)
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
print_success("Waiting for approval in the browser…")
|
|
103
|
+
deadline = time.monotonic() + expires_in
|
|
104
|
+
while time.monotonic() < deadline:
|
|
105
|
+
time.sleep(max(1.0, interval))
|
|
106
|
+
poll = auth_api.device_poll(client, device_code).get("data") or {}
|
|
107
|
+
status = str(poll.get("status") or "").lower()
|
|
108
|
+
if status == "approved":
|
|
109
|
+
_store_tokens(app_ctx.profile_name, poll)
|
|
110
|
+
print_success(f"Logged in for profile '{app_ctx.profile_name}'.")
|
|
111
|
+
_print_next_steps(app_ctx)
|
|
112
|
+
return
|
|
113
|
+
if status in {"expired", "denied"}:
|
|
114
|
+
raise typer.BadParameter(f"Device login {status}.")
|
|
115
|
+
raise typer.BadParameter("Device login timed out.")
|
|
116
|
+
|
|
117
|
+
from caraer_cli.wizard.prompts import require_text
|
|
118
|
+
|
|
119
|
+
email = require_text(email, "Email", flag="--email")
|
|
120
|
+
resolved_password = password or getpass.getpass("Password: ")
|
|
121
|
+
response = auth_api.login(client, email, resolved_password)
|
|
122
|
+
data = response.get("data", {})
|
|
123
|
+
_store_tokens(app_ctx.profile_name, data if isinstance(data, dict) else {})
|
|
124
|
+
print_success(f"Logged in for profile '{app_ctx.profile_name}'.")
|
|
125
|
+
_print_next_steps(app_ctx)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_next_steps(app_ctx: AppContext) -> None:
|
|
129
|
+
if app_ctx.profile.company_uuid:
|
|
130
|
+
print_success(
|
|
131
|
+
f"Profile company is '{app_ctx.profile.company_uuid}'. "
|
|
132
|
+
"If API calls fail with Company not found, run 'caraer company clear' "
|
|
133
|
+
"then 'caraer company list'."
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
print_success("Next: run 'caraer company list' and 'caraer company select <uuid>'.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command("logout")
|
|
140
|
+
def logout(ctx: typer.Context) -> None:
|
|
141
|
+
"""Log out and clear the stored session token for the active profile."""
|
|
142
|
+
app_ctx: AppContext = ctx.obj
|
|
143
|
+
client = app_ctx.api_client()
|
|
144
|
+
try:
|
|
145
|
+
auth_api.logout(client)
|
|
146
|
+
except AuthError:
|
|
147
|
+
pass
|
|
148
|
+
session_store.clear_session_token(app_ctx.profile_name)
|
|
149
|
+
print_success(f"Logged out profile '{app_ctx.profile_name}'.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command("me")
|
|
153
|
+
def me(ctx: typer.Context) -> None:
|
|
154
|
+
"""Show the currently authenticated user."""
|
|
155
|
+
app_ctx: AppContext = ctx.obj
|
|
156
|
+
client = app_ctx.api_client()
|
|
157
|
+
response = auth_api.me(client)
|
|
158
|
+
print_data(response.get("data"), app_ctx.output)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("refresh")
|
|
162
|
+
def refresh_token(ctx: typer.Context) -> None:
|
|
163
|
+
"""Refresh the access token using the stored refresh token."""
|
|
164
|
+
app_ctx: AppContext = ctx.obj
|
|
165
|
+
refresh = session_store.get_refresh_token(app_ctx.profile_name)
|
|
166
|
+
if not refresh:
|
|
167
|
+
raise typer.BadParameter(
|
|
168
|
+
"No refresh token stored. Run 'caraer auth login' (device flow) first."
|
|
169
|
+
)
|
|
170
|
+
client = app_ctx.api_client()
|
|
171
|
+
data = auth_api.refresh(client, refresh).get("data") or {}
|
|
172
|
+
_store_tokens(app_ctx.profile_name, data if isinstance(data, dict) else {})
|
|
173
|
+
print_success(f"Refreshed session for profile '{app_ctx.profile_name}'.")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from caraer_cli.api import auth as auth_api
|
|
6
|
+
from caraer_cli.completion_callbacks import complete_company
|
|
7
|
+
from caraer_cli.context import AppContext
|
|
8
|
+
from caraer_cli.formatters.output import print_data, print_success
|
|
9
|
+
from caraer_cli.state.config import save_config
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Company context commands.", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_companies(ctx: typer.Context) -> None:
|
|
16
|
+
"""List companies available to the authenticated user."""
|
|
17
|
+
app_ctx: AppContext = ctx.obj
|
|
18
|
+
client = app_ctx.api_client()
|
|
19
|
+
response = auth_api.companies(client)
|
|
20
|
+
companies = response.get("data")
|
|
21
|
+
if isinstance(companies, list):
|
|
22
|
+
minimal = []
|
|
23
|
+
for company in companies:
|
|
24
|
+
if not isinstance(company, dict):
|
|
25
|
+
continue
|
|
26
|
+
minimal.append(
|
|
27
|
+
{
|
|
28
|
+
"name": company.get("name"),
|
|
29
|
+
"uuid": company.get("uuid"),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
print_data(minimal, app_ctx.output)
|
|
33
|
+
return
|
|
34
|
+
print_data([], app_ctx.output)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("select")
|
|
38
|
+
def select_company(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
company_uuid: str | None = typer.Argument(
|
|
41
|
+
None,
|
|
42
|
+
help="Company UUID to use for API calls. Prompted if omitted.",
|
|
43
|
+
autocompletion=complete_company,
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Select a company for the active profile."""
|
|
47
|
+
from caraer_cli.wizard.prompts import require_text
|
|
48
|
+
|
|
49
|
+
app_ctx: AppContext = ctx.obj
|
|
50
|
+
company_uuid = require_text(company_uuid, "Company UUID", flag="company-uuid")
|
|
51
|
+
cfg = app_ctx.config
|
|
52
|
+
profile = cfg.profiles[app_ctx.profile_name]
|
|
53
|
+
profile.company_uuid = company_uuid
|
|
54
|
+
profile.sandbox_uuid = None # sandbox is always scoped to its owner company
|
|
55
|
+
save_config(cfg)
|
|
56
|
+
print_success(f"Selected company '{company_uuid}' for profile '{app_ctx.profile_name}'.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("clear")
|
|
60
|
+
def clear_company(ctx: typer.Context) -> None:
|
|
61
|
+
"""Remove the selected company from the active profile."""
|
|
62
|
+
app_ctx: AppContext = ctx.obj
|
|
63
|
+
cfg = app_ctx.config
|
|
64
|
+
profile = cfg.profiles[app_ctx.profile_name]
|
|
65
|
+
profile.company_uuid = None
|
|
66
|
+
profile.sandbox_uuid = None
|
|
67
|
+
save_config(cfg)
|
|
68
|
+
print_success(f"Cleared company for profile '{app_ctx.profile_name}'.")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Installation runtime commands: state, secrets, jobs, connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from caraer_cli.context import AppContext
|
|
11
|
+
from caraer_cli.formatters.output import print_data, print_success
|
|
12
|
+
|
|
13
|
+
state_app = typer.Typer(help="Installation state (key/value store).", no_args_is_help=True)
|
|
14
|
+
secrets_app = typer.Typer(help="Installation secrets.", no_args_is_help=True)
|
|
15
|
+
jobs_app = typer.Typer(help="Installation background jobs.", no_args_is_help=True)
|
|
16
|
+
connections_app = typer.Typer(help="External OAuth connections.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_app_uuid(ctx: typer.Context) -> tuple[AppContext, str]:
|
|
20
|
+
from caraer_cli.app_sync import resolve_app_root
|
|
21
|
+
from caraer_cli.project.schema import load_workspace
|
|
22
|
+
|
|
23
|
+
app_ctx: AppContext = ctx.obj
|
|
24
|
+
root = resolve_app_root(app_file=app_ctx.profile.app_file)
|
|
25
|
+
config = load_workspace(root)
|
|
26
|
+
if not config.appUuid:
|
|
27
|
+
raise ValueError("App has no remote UUID. Run 'caraer apps select' or 'caraer apps push'.")
|
|
28
|
+
return app_ctx, config.appUuid
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@state_app.command("get")
|
|
32
|
+
def state_get(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
key: str | None = typer.Argument(None, help="Optional state key (omit for full map)."),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Get installation state (all keys, or one key)."""
|
|
37
|
+
from caraer_cli.api import installation as installation_api
|
|
38
|
+
|
|
39
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
40
|
+
client = app_ctx.api_client()
|
|
41
|
+
if key:
|
|
42
|
+
response = installation_api.get_state_key(client, app_uuid, key)
|
|
43
|
+
else:
|
|
44
|
+
response = installation_api.get_state(client, app_uuid)
|
|
45
|
+
print_data(response.get("data"), app_ctx.output)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@state_app.command("set")
|
|
49
|
+
def state_set(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
key: str = typer.Argument(..., help="State key."),
|
|
52
|
+
value: str = typer.Argument(..., help="JSON or plain string value."),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Set an installation state key."""
|
|
55
|
+
from caraer_cli.api import installation as installation_api
|
|
56
|
+
|
|
57
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
58
|
+
try:
|
|
59
|
+
parsed: Any = json.loads(value)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
parsed = value
|
|
62
|
+
response = installation_api.put_state_key(app_ctx.api_client(), app_uuid, key, parsed)
|
|
63
|
+
print_success(f"Set state key '{key}'")
|
|
64
|
+
print_data(response.get("data"), app_ctx.output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@state_app.command("delete")
|
|
68
|
+
def state_delete(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
key: str = typer.Argument(..., help="State key to delete."),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Delete an installation state key."""
|
|
73
|
+
from caraer_cli.api import installation as installation_api
|
|
74
|
+
|
|
75
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
76
|
+
response = installation_api.delete_state_key(app_ctx.api_client(), app_uuid, key)
|
|
77
|
+
print_success(f"Deleted state key '{key}'")
|
|
78
|
+
print_data(response.get("data"), app_ctx.output)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@secrets_app.command("list")
|
|
82
|
+
def secrets_list(ctx: typer.Context) -> None:
|
|
83
|
+
"""List installation secret names (values are never returned)."""
|
|
84
|
+
from caraer_cli.api import installation as installation_api
|
|
85
|
+
|
|
86
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
87
|
+
response = installation_api.list_secrets(app_ctx.api_client(), app_uuid)
|
|
88
|
+
print_data(response.get("data"), app_ctx.output)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@secrets_app.command("set")
|
|
92
|
+
def secrets_set(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
name: str = typer.Argument(..., help="Secret name."),
|
|
95
|
+
value: str = typer.Argument(..., help="Secret value."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Create or update an installation secret."""
|
|
98
|
+
from caraer_cli.api import installation as installation_api
|
|
99
|
+
|
|
100
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
101
|
+
response = installation_api.put_secret(app_ctx.api_client(), app_uuid, name, value)
|
|
102
|
+
print_success(f"Set secret '{name}'")
|
|
103
|
+
print_data(response.get("data"), app_ctx.output)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@secrets_app.command("delete")
|
|
107
|
+
def secrets_delete(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
name: str = typer.Argument(..., help="Secret name to delete."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Delete an installation secret."""
|
|
112
|
+
from caraer_cli.api import installation as installation_api
|
|
113
|
+
|
|
114
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
115
|
+
response = installation_api.delete_secret(app_ctx.api_client(), app_uuid, name)
|
|
116
|
+
print_success(f"Deleted secret '{name}'")
|
|
117
|
+
print_data(response.get("data"), app_ctx.output)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@jobs_app.command("enqueue")
|
|
121
|
+
def jobs_enqueue(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
function_name: str = typer.Argument(..., help="Function name to invoke."),
|
|
124
|
+
payload: str = typer.Option("{}", "--payload", help="JSON payload object."),
|
|
125
|
+
delay_seconds: float = typer.Option(0, "--delay", help="Delay before invoke (seconds)."),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Enqueue a background job for a function."""
|
|
128
|
+
from caraer_cli.api import installation as installation_api
|
|
129
|
+
|
|
130
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
131
|
+
try:
|
|
132
|
+
body = json.loads(payload)
|
|
133
|
+
except json.JSONDecodeError as exc:
|
|
134
|
+
raise ValueError(f"Invalid --payload JSON: {exc}") from exc
|
|
135
|
+
if not isinstance(body, dict):
|
|
136
|
+
raise ValueError("--payload must be a JSON object")
|
|
137
|
+
response = installation_api.enqueue_job(
|
|
138
|
+
app_ctx.api_client(),
|
|
139
|
+
app_uuid,
|
|
140
|
+
function_name=function_name,
|
|
141
|
+
payload=body,
|
|
142
|
+
delay_seconds=delay_seconds,
|
|
143
|
+
)
|
|
144
|
+
print_success("Job enqueued")
|
|
145
|
+
print_data(response.get("data"), app_ctx.output)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@jobs_app.command("status")
|
|
149
|
+
def jobs_status(
|
|
150
|
+
ctx: typer.Context,
|
|
151
|
+
job_id: str = typer.Argument(..., help="Job id from enqueue."),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Get status for an installation job."""
|
|
154
|
+
from caraer_cli.api import installation as installation_api
|
|
155
|
+
|
|
156
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
157
|
+
response = installation_api.get_job(app_ctx.api_client(), app_uuid, job_id)
|
|
158
|
+
print_data(response.get("data"), app_ctx.output)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@connections_app.command("list")
|
|
162
|
+
def connections_list(ctx: typer.Context) -> None:
|
|
163
|
+
"""List external OAuth connection status for this app installation."""
|
|
164
|
+
from caraer_cli.api import installation as installation_api
|
|
165
|
+
|
|
166
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
167
|
+
response = installation_api.list_connections(app_ctx.api_client(), app_uuid)
|
|
168
|
+
print_data(response.get("data"), app_ctx.output)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@connections_app.command("revoke")
|
|
172
|
+
def connections_revoke(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
provider: str = typer.Argument(..., help="Provider name to revoke."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Revoke an external OAuth connection."""
|
|
177
|
+
from caraer_cli.api import installation as installation_api
|
|
178
|
+
|
|
179
|
+
app_ctx, app_uuid = _require_app_uuid(ctx)
|
|
180
|
+
response = installation_api.delete_connection(app_ctx.api_client(), app_uuid, provider)
|
|
181
|
+
print_success(f"Revoked connection '{provider}'")
|
|
182
|
+
print_data(response.get("data"), app_ctx.output)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from caraer_cli.completion_callbacks import complete_output_format, complete_profile
|
|
8
|
+
from caraer_cli.context import AppContext
|
|
9
|
+
from caraer_cli.formatters.output import print_data, print_success
|
|
10
|
+
from caraer_cli.state.config import ProfileConfig, profile_as_dict, save_config
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage CLI config profiles.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require_profile(app_ctx: AppContext, name: str) -> ProfileConfig:
|
|
16
|
+
if name not in app_ctx.config.profiles:
|
|
17
|
+
raise typer.BadParameter(f"Unknown profile '{name}'.")
|
|
18
|
+
return app_ctx.config.profiles[name]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_target(app_ctx: AppContext, name: str | None) -> tuple[str, ProfileConfig]:
|
|
22
|
+
target = (name or app_ctx.profile_name).strip()
|
|
23
|
+
return target, _require_profile(app_ctx, target)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("list")
|
|
27
|
+
def list_profiles(ctx: typer.Context) -> None:
|
|
28
|
+
"""List all profiles (marks the active one)."""
|
|
29
|
+
app_ctx: AppContext = ctx.obj
|
|
30
|
+
rows = []
|
|
31
|
+
for name, profile in app_ctx.config.profiles.items():
|
|
32
|
+
row = profile_as_dict(profile)
|
|
33
|
+
row["name"] = name
|
|
34
|
+
row["active"] = name == app_ctx.config.active_profile
|
|
35
|
+
rows.append(row)
|
|
36
|
+
print_data(rows, app_ctx.output)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("show")
|
|
40
|
+
def show_profile(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
name: str | None = typer.Argument(
|
|
43
|
+
None,
|
|
44
|
+
help="Profile name (defaults to the active profile).",
|
|
45
|
+
autocompletion=complete_profile,
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Show one profile's settings."""
|
|
49
|
+
app_ctx: AppContext = ctx.obj
|
|
50
|
+
target, profile = _resolve_target(app_ctx, name)
|
|
51
|
+
output = profile_as_dict(profile)
|
|
52
|
+
output["name"] = target
|
|
53
|
+
output["active"] = target == app_ctx.config.active_profile
|
|
54
|
+
print_data(output, app_ctx.output)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("current")
|
|
58
|
+
def current_profile(ctx: typer.Context) -> None:
|
|
59
|
+
"""Show the active profile name and settings."""
|
|
60
|
+
app_ctx: AppContext = ctx.obj
|
|
61
|
+
output = profile_as_dict(app_ctx.profile)
|
|
62
|
+
output["name"] = app_ctx.profile_name
|
|
63
|
+
output["active"] = True
|
|
64
|
+
print_data(output, app_ctx.output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("use")
|
|
68
|
+
def use_profile(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
name: str | None = typer.Argument(
|
|
71
|
+
None,
|
|
72
|
+
help="Profile name to activate. Prompted if omitted.",
|
|
73
|
+
autocompletion=complete_profile,
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Switch the active profile."""
|
|
77
|
+
from caraer_cli.wizard.prompts import require_text
|
|
78
|
+
|
|
79
|
+
app_ctx: AppContext = ctx.obj
|
|
80
|
+
name = require_text(name, "Profile name", flag="name")
|
|
81
|
+
_require_profile(app_ctx, name)
|
|
82
|
+
app_ctx.config.active_profile = name
|
|
83
|
+
save_config(app_ctx.config)
|
|
84
|
+
print_success(f"Set active profile to '{name}'.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("create")
|
|
88
|
+
def create_profile(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
name: str | None = typer.Argument(None, help="New profile name. Prompted if omitted."),
|
|
91
|
+
base_url: str | None = typer.Option(
|
|
92
|
+
None,
|
|
93
|
+
"--base-url",
|
|
94
|
+
help="API base URL (required unless --from is set; prompted if omitted).",
|
|
95
|
+
),
|
|
96
|
+
from_profile: str | None = typer.Option(
|
|
97
|
+
None,
|
|
98
|
+
"--from",
|
|
99
|
+
help="Copy settings from an existing profile.",
|
|
100
|
+
autocompletion=complete_profile,
|
|
101
|
+
),
|
|
102
|
+
use: bool = typer.Option(False, "--use", help="Make this the active profile."),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Create a new profile, optionally copied from an existing one."""
|
|
105
|
+
from caraer_cli.wizard.prompts import require_text
|
|
106
|
+
|
|
107
|
+
app_ctx: AppContext = ctx.obj
|
|
108
|
+
name = require_text(name, "Profile name", flag="name")
|
|
109
|
+
if name in app_ctx.config.profiles:
|
|
110
|
+
raise typer.BadParameter(f"Profile '{name}' already exists.")
|
|
111
|
+
|
|
112
|
+
if from_profile:
|
|
113
|
+
source = _require_profile(app_ctx, from_profile)
|
|
114
|
+
profile = deepcopy(source)
|
|
115
|
+
if base_url:
|
|
116
|
+
profile.base_url = base_url
|
|
117
|
+
else:
|
|
118
|
+
base_url = require_text(base_url, "API base URL", flag="--base-url")
|
|
119
|
+
profile = ProfileConfig(base_url=base_url)
|
|
120
|
+
|
|
121
|
+
app_ctx.config.profiles[name] = profile
|
|
122
|
+
if use:
|
|
123
|
+
app_ctx.config.active_profile = name
|
|
124
|
+
save_config(app_ctx.config)
|
|
125
|
+
message = f"Created profile '{name}'."
|
|
126
|
+
if use:
|
|
127
|
+
message += " Set as active."
|
|
128
|
+
print_success(message)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("delete")
|
|
132
|
+
def delete_profile(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
name: str | None = typer.Argument(
|
|
135
|
+
None,
|
|
136
|
+
help="Profile name to delete. Prompted if omitted.",
|
|
137
|
+
autocompletion=complete_profile,
|
|
138
|
+
),
|
|
139
|
+
force: bool = typer.Option(False, "--force", help="Allow deleting the active profile."),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Delete a profile."""
|
|
142
|
+
from caraer_cli.wizard.prompts import require_text
|
|
143
|
+
|
|
144
|
+
app_ctx: AppContext = ctx.obj
|
|
145
|
+
name = require_text(name, "Profile name", flag="name")
|
|
146
|
+
_require_profile(app_ctx, name)
|
|
147
|
+
if len(app_ctx.config.profiles) <= 1:
|
|
148
|
+
raise typer.BadParameter("Cannot delete the last remaining profile.")
|
|
149
|
+
if name == app_ctx.config.active_profile and not force:
|
|
150
|
+
raise typer.BadParameter(
|
|
151
|
+
f"'{name}' is the active profile. Switch first, or pass --force."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
del app_ctx.config.profiles[name]
|
|
155
|
+
if app_ctx.config.active_profile == name:
|
|
156
|
+
app_ctx.config.active_profile = next(iter(app_ctx.config.profiles.keys()))
|
|
157
|
+
save_config(app_ctx.config)
|
|
158
|
+
print_success(f"Deleted profile '{name}'.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("set")
|
|
162
|
+
def set_profile(
|
|
163
|
+
ctx: typer.Context,
|
|
164
|
+
name: str | None = typer.Argument(
|
|
165
|
+
None,
|
|
166
|
+
help="Profile to update (defaults to the active profile).",
|
|
167
|
+
autocompletion=complete_profile,
|
|
168
|
+
),
|
|
169
|
+
base_url: str | None = typer.Option(None, "--base-url", help="API base URL."),
|
|
170
|
+
company_uuid: str | None = typer.Option(None, "--company-uuid", help="Selected company UUID."),
|
|
171
|
+
app_uuid: str | None = typer.Option(None, "--app-uuid", help="Selected remote app UUID."),
|
|
172
|
+
app_file: str | None = typer.Option(None, "--app-file", help="Selected local app file path."),
|
|
173
|
+
output: str | None = typer.Option(
|
|
174
|
+
None,
|
|
175
|
+
"--output",
|
|
176
|
+
help="Default output format: table|json|yaml.",
|
|
177
|
+
autocompletion=complete_output_format,
|
|
178
|
+
),
|
|
179
|
+
timeout_seconds: float | None = typer.Option(
|
|
180
|
+
None,
|
|
181
|
+
"--timeout-seconds",
|
|
182
|
+
help="HTTP timeout in seconds.",
|
|
183
|
+
),
|
|
184
|
+
verify_ssl: bool | None = typer.Option(
|
|
185
|
+
None,
|
|
186
|
+
"--verify-ssl/--no-verify-ssl",
|
|
187
|
+
help="Verify TLS certificates.",
|
|
188
|
+
),
|
|
189
|
+
clear_company: bool = typer.Option(False, "--clear-company", help="Clear company_uuid."),
|
|
190
|
+
clear_app: bool = typer.Option(False, "--clear-app", help="Clear app_uuid."),
|
|
191
|
+
clear_app_file: bool = typer.Option(False, "--clear-app-file", help="Clear app_file."),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Update fields on a profile."""
|
|
194
|
+
app_ctx: AppContext = ctx.obj
|
|
195
|
+
target, profile = _resolve_target(app_ctx, name)
|
|
196
|
+
|
|
197
|
+
updates: list[str] = []
|
|
198
|
+
if base_url is not None:
|
|
199
|
+
profile.base_url = base_url
|
|
200
|
+
updates.append("base_url")
|
|
201
|
+
if company_uuid is not None:
|
|
202
|
+
profile.company_uuid = company_uuid.strip() or None
|
|
203
|
+
updates.append("company_uuid")
|
|
204
|
+
if app_uuid is not None:
|
|
205
|
+
profile.app_uuid = app_uuid.strip() or None
|
|
206
|
+
updates.append("app_uuid")
|
|
207
|
+
if app_file is not None:
|
|
208
|
+
profile.app_file = app_file.strip() or None
|
|
209
|
+
updates.append("app_file")
|
|
210
|
+
if output is not None:
|
|
211
|
+
normalized = output.strip().lower()
|
|
212
|
+
if normalized not in {"table", "json", "yaml"}:
|
|
213
|
+
raise typer.BadParameter("output must be one of: table, json, yaml.")
|
|
214
|
+
profile.output = normalized
|
|
215
|
+
updates.append("output")
|
|
216
|
+
if timeout_seconds is not None:
|
|
217
|
+
if timeout_seconds <= 0:
|
|
218
|
+
raise typer.BadParameter("timeout-seconds must be greater than 0.")
|
|
219
|
+
profile.timeout_seconds = timeout_seconds
|
|
220
|
+
updates.append("timeout_seconds")
|
|
221
|
+
if verify_ssl is not None:
|
|
222
|
+
profile.verify_ssl = verify_ssl
|
|
223
|
+
updates.append("verify_ssl")
|
|
224
|
+
if clear_company:
|
|
225
|
+
profile.company_uuid = None
|
|
226
|
+
updates.append("company_uuid")
|
|
227
|
+
if clear_app:
|
|
228
|
+
profile.app_uuid = None
|
|
229
|
+
updates.append("app_uuid")
|
|
230
|
+
if clear_app_file:
|
|
231
|
+
profile.app_file = None
|
|
232
|
+
updates.append("app_file")
|
|
233
|
+
|
|
234
|
+
if not updates:
|
|
235
|
+
raise typer.BadParameter("No fields to update. Pass at least one option.")
|
|
236
|
+
|
|
237
|
+
save_config(app_ctx.config)
|
|
238
|
+
unique = ", ".join(dict.fromkeys(updates))
|
|
239
|
+
print_success(f"Updated {unique} on profile '{target}'.")
|