urun-cli 0.5.0__tar.gz → 0.5.2__tar.gz
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.
- {urun_cli-0.5.0 → urun_cli-0.5.2}/.gitignore +1 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/PKG-INFO +35 -1
- {urun_cli-0.5.0 → urun_cli-0.5.2}/README.md +34 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/pyproject.toml +1 -1
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/api.py +237 -0
- urun_cli-0.5.2/src/urun/catalog.py +652 -0
- urun_cli-0.5.2/src/urun/cli.py +2206 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/deps.py +149 -7
- urun_cli-0.5.2/src/urun/serve.py +524 -0
- urun_cli-0.5.0/src/urun/cli.py +0 -1159
- {urun_cli-0.5.0 → urun_cli-0.5.2}/CHANGELOG.md +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/LICENSE +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/SECURITY.md +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/__init__.py +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/config.py +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/discovery.py +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/errors.py +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/manifest.py +0 -0
- {urun_cli-0.5.0 → urun_cli-0.5.2}/src/urun/retry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: urun-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: End-user CLI for deploying apps to urun
|
|
5
5
|
Project-URL: Homepage, https://urun.sh
|
|
6
6
|
Project-URL: Repository, https://github.com/urun-sh/urun-cli
|
|
@@ -93,6 +93,40 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
|
|
|
93
93
|
`deploy` remains available as the lower-level command while the full
|
|
94
94
|
deploy/run/monitor workflow is being built.
|
|
95
95
|
|
|
96
|
+
## Serve a model from the catalog
|
|
97
|
+
|
|
98
|
+
`urun serve` deploys a model straight from the urun model catalog — no app code
|
|
99
|
+
required. It is `urun deploy` with a serve app templated from the resolved
|
|
100
|
+
catalog row (engine, HF repo, GPU, engine args).
|
|
101
|
+
|
|
102
|
+
List the enumerable model matrix (models, variants, the GPU each fits, engine
|
|
103
|
+
shape, and whether the placement is dev-testable or prod-only):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
urun serve catalog
|
|
107
|
+
urun serve catalog --json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Serve a model. With no variant the model's default (first) variant is used; with
|
|
111
|
+
no `--gpu` the variant's first placement is used:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
urun serve qwen-coder # default variant + first placement
|
|
115
|
+
urun serve glm-5.2:UD-IQ2_M # explicit <id>:<variant>
|
|
116
|
+
urun serve qwen-coder-480b:fp8 --gpu b200:8
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The catalog is anon-readable reference data published by a urun-infra migration.
|
|
120
|
+
Reads go over PostgREST on the same control-plane host as the API URL; supply the
|
|
121
|
+
public Supabase anon key via `--anon-key` or `URUN_CATALOG_ANON_KEY`
|
|
122
|
+
(`URUN_SUPABASE_ANON_KEY` is also honored). Override the PostgREST base with
|
|
123
|
+
`--catalog-url` / `URUN_CATALOG_URL` if needed.
|
|
124
|
+
|
|
125
|
+
By default `urun serve <id>` renders a templated serve app and deploys it. If you
|
|
126
|
+
maintain your own `_serve` app, point `--entrypoint` at it; the CLI deploys that
|
|
127
|
+
app with `URUN_SERVE_CONFIG` and `URUN_SERVE_CATALOG_ROW` set to the resolved
|
|
128
|
+
row instead of rendering one.
|
|
129
|
+
|
|
96
130
|
## Inspect apps
|
|
97
131
|
|
|
98
132
|
List every app deployed in your org and its current status:
|
|
@@ -71,6 +71,40 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
|
|
|
71
71
|
`deploy` remains available as the lower-level command while the full
|
|
72
72
|
deploy/run/monitor workflow is being built.
|
|
73
73
|
|
|
74
|
+
## Serve a model from the catalog
|
|
75
|
+
|
|
76
|
+
`urun serve` deploys a model straight from the urun model catalog — no app code
|
|
77
|
+
required. It is `urun deploy` with a serve app templated from the resolved
|
|
78
|
+
catalog row (engine, HF repo, GPU, engine args).
|
|
79
|
+
|
|
80
|
+
List the enumerable model matrix (models, variants, the GPU each fits, engine
|
|
81
|
+
shape, and whether the placement is dev-testable or prod-only):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
urun serve catalog
|
|
85
|
+
urun serve catalog --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Serve a model. With no variant the model's default (first) variant is used; with
|
|
89
|
+
no `--gpu` the variant's first placement is used:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
urun serve qwen-coder # default variant + first placement
|
|
93
|
+
urun serve glm-5.2:UD-IQ2_M # explicit <id>:<variant>
|
|
94
|
+
urun serve qwen-coder-480b:fp8 --gpu b200:8
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The catalog is anon-readable reference data published by a urun-infra migration.
|
|
98
|
+
Reads go over PostgREST on the same control-plane host as the API URL; supply the
|
|
99
|
+
public Supabase anon key via `--anon-key` or `URUN_CATALOG_ANON_KEY`
|
|
100
|
+
(`URUN_SUPABASE_ANON_KEY` is also honored). Override the PostgREST base with
|
|
101
|
+
`--catalog-url` / `URUN_CATALOG_URL` if needed.
|
|
102
|
+
|
|
103
|
+
By default `urun serve <id>` renders a templated serve app and deploys it. If you
|
|
104
|
+
maintain your own `_serve` app, point `--entrypoint` at it; the CLI deploys that
|
|
105
|
+
app with `URUN_SERVE_CONFIG` and `URUN_SERVE_CATALOG_ROW` set to the resolved
|
|
106
|
+
row instead of rendering one.
|
|
107
|
+
|
|
74
108
|
## Inspect apps
|
|
75
109
|
|
|
76
110
|
List every app deployed in your org and its current status:
|
|
@@ -43,6 +43,21 @@ class ApiClient:
|
|
|
43
43
|
def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
|
|
44
44
|
return self._json("GET", f"/deployment-status/{manifest_hash}", None)
|
|
45
45
|
|
|
46
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
47
|
+
return self._json("GET", self._path(path, params), None)
|
|
48
|
+
|
|
49
|
+
def _post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
50
|
+
return self._json("POST", path, body or {})
|
|
51
|
+
|
|
52
|
+
def _delete(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
53
|
+
return self._json("DELETE", path, body)
|
|
54
|
+
|
|
55
|
+
def _path(self, path: str, params: dict[str, Any] | None = None) -> str:
|
|
56
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None and v is not False}
|
|
57
|
+
if not clean:
|
|
58
|
+
return path
|
|
59
|
+
return path + "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
60
|
+
|
|
46
61
|
def list_apps(self) -> dict[str, Any]:
|
|
47
62
|
"""List all deployed apps for the authenticated org.
|
|
48
63
|
|
|
@@ -58,6 +73,15 @@ class ApiClient:
|
|
|
58
73
|
"""
|
|
59
74
|
return self._json("GET", "/apps", None)
|
|
60
75
|
|
|
76
|
+
def get_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
77
|
+
return self.app_status(app, environment=environment)
|
|
78
|
+
|
|
79
|
+
def diagnose_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
80
|
+
return self._get(
|
|
81
|
+
f"/cli/apps/{urllib.parse.quote(app, safe='')}/diagnose",
|
|
82
|
+
{"environment": environment},
|
|
83
|
+
)
|
|
84
|
+
|
|
61
85
|
def list_sessions(self, limit: int | None = None, state: str | None = None) -> dict[str, Any]:
|
|
62
86
|
"""List sessions for the authenticated org.
|
|
63
87
|
|
|
@@ -84,6 +108,15 @@ class ApiClient:
|
|
|
84
108
|
path += "?" + urllib.parse.urlencode(params)
|
|
85
109
|
return self._json("GET", path, None)
|
|
86
110
|
|
|
111
|
+
def get_session(self, session_id: str) -> dict[str, Any]:
|
|
112
|
+
return self._get(f"/cli/sessions/{urllib.parse.quote(session_id, safe='')}")
|
|
113
|
+
|
|
114
|
+
def cancel_session(self, session_id: str, reason: str | None = None) -> dict[str, Any]:
|
|
115
|
+
return self._post(
|
|
116
|
+
f"/cli/sessions/{urllib.parse.quote(session_id, safe='')}/cancel",
|
|
117
|
+
{"reason": reason} if reason else {},
|
|
118
|
+
)
|
|
119
|
+
|
|
87
120
|
def list_compute(self, limit: int | None = None) -> dict[str, Any]:
|
|
88
121
|
"""List currently-provisioned compute slices for the authenticated org.
|
|
89
122
|
|
|
@@ -110,6 +143,210 @@ class ApiClient:
|
|
|
110
143
|
path += "?" + urllib.parse.urlencode({"limit": str(limit)})
|
|
111
144
|
return self._json("GET", path, None)
|
|
112
145
|
|
|
146
|
+
def list_capacity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
147
|
+
return self._get("/cli/capacity", params)
|
|
148
|
+
|
|
149
|
+
def get_capacity(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
150
|
+
return self._get(
|
|
151
|
+
f"/cli/capacity/{urllib.parse.quote(app, safe='')}", {"environment": environment}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def list_shapes(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
155
|
+
return self._get("/cli/shapes", params)
|
|
156
|
+
|
|
157
|
+
def get_shape(self, shape: str) -> dict[str, Any]:
|
|
158
|
+
return self._get(f"/cli/shapes/{urllib.parse.quote(shape, safe='')}")
|
|
159
|
+
|
|
160
|
+
def list_capacity_leases(self, status: str | None = None) -> dict[str, Any]:
|
|
161
|
+
return self._get("/cli/capacity-leases", {"status": status})
|
|
162
|
+
|
|
163
|
+
def get_capacity_lease(self, lease_id: str) -> dict[str, Any]:
|
|
164
|
+
return self._get(f"/cli/capacity-leases/{urllib.parse.quote(lease_id, safe='')}")
|
|
165
|
+
|
|
166
|
+
def create_capacity_lease(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
return self._post("/cli/capacity-leases", payload)
|
|
168
|
+
|
|
169
|
+
def cancel_capacity_lease(self, lease_id: str) -> dict[str, Any]:
|
|
170
|
+
return self._post(f"/cli/capacity-leases/{urllib.parse.quote(lease_id, safe='')}/cancel")
|
|
171
|
+
|
|
172
|
+
def list_builds(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
173
|
+
return self._get("/cli/builds", params)
|
|
174
|
+
|
|
175
|
+
def get_build(self, build_id: str) -> dict[str, Any]:
|
|
176
|
+
return self._get(f"/cli/builds/{urllib.parse.quote(build_id, safe='')}")
|
|
177
|
+
|
|
178
|
+
def logs_build(self, build_id: str, tail: int | None = None) -> dict[str, Any]:
|
|
179
|
+
return self._get(
|
|
180
|
+
f"/cli/builds/{urllib.parse.quote(build_id, safe='')}/logs", {"tail": tail}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
184
|
+
return self._get("/cli/releases", {"app": app, "environment": environment})
|
|
185
|
+
|
|
186
|
+
def get_release(self, release_id: str) -> dict[str, Any]:
|
|
187
|
+
return self._get(f"/cli/releases/{urllib.parse.quote(release_id, safe='')}")
|
|
188
|
+
|
|
189
|
+
def query_logs(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
190
|
+
return self._get("/cli/logs", params)
|
|
191
|
+
|
|
192
|
+
def list_events(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
193
|
+
return self._get("/cli/events", params)
|
|
194
|
+
|
|
195
|
+
def list_environments(self) -> dict[str, Any]:
|
|
196
|
+
return self._get("/cli/environments")
|
|
197
|
+
|
|
198
|
+
def get_environment(self, environment: str) -> dict[str, Any]:
|
|
199
|
+
return self._get(f"/cli/environments/{urllib.parse.quote(environment, safe='')}")
|
|
200
|
+
|
|
201
|
+
def create_environment(
|
|
202
|
+
self, environment: str, display_name: str | None = None
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
body: dict[str, Any] = {"environment": environment}
|
|
205
|
+
if display_name:
|
|
206
|
+
body["display_name"] = display_name
|
|
207
|
+
return self._post("/cli/environments", body)
|
|
208
|
+
|
|
209
|
+
def get_usage(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
210
|
+
return self._get("/cli/usage", params)
|
|
211
|
+
|
|
212
|
+
def get_credits(self) -> dict[str, Any]:
|
|
213
|
+
return self._get("/cli/credits")
|
|
214
|
+
|
|
215
|
+
def list_credit_ledger(self, limit: int | None = None) -> dict[str, Any]:
|
|
216
|
+
return self._get("/cli/credit-ledger", {"limit": limit})
|
|
217
|
+
|
|
218
|
+
def get_quota(self, shape: str | None = None) -> dict[str, Any]:
|
|
219
|
+
return self._get("/cli/quota", {"shape": shape})
|
|
220
|
+
|
|
221
|
+
def get_billing(self) -> dict[str, Any]:
|
|
222
|
+
return self._get("/cli/billing")
|
|
223
|
+
|
|
224
|
+
def list_invoices(self) -> dict[str, Any]:
|
|
225
|
+
return self._get("/cli/invoices")
|
|
226
|
+
|
|
227
|
+
def list_secrets(self, app: str | None = None) -> dict[str, Any]:
|
|
228
|
+
return self._get("/cli/secrets", {"app": app})
|
|
229
|
+
|
|
230
|
+
def set_secret(self, name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
231
|
+
return self._post(f"/cli/secrets/{urllib.parse.quote(name, safe='')}", payload)
|
|
232
|
+
|
|
233
|
+
def delete_secret(self, name: str, app: str | None = None) -> dict[str, Any]:
|
|
234
|
+
return self._delete(
|
|
235
|
+
f"/cli/secrets/{urllib.parse.quote(name, safe='')}", {"app": app} if app else None
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def list_api_keys(self) -> dict[str, Any]:
|
|
239
|
+
return self._get("/cli/api-keys")
|
|
240
|
+
|
|
241
|
+
def create_api_key(self, label: str, service_account: str | None = None) -> dict[str, Any]:
|
|
242
|
+
return self._post("/cli/api-keys", {"label": label, "service_account": service_account})
|
|
243
|
+
|
|
244
|
+
def revoke_api_key(self, key_id: str) -> dict[str, Any]:
|
|
245
|
+
return self._post(f"/cli/api-keys/{urllib.parse.quote(key_id, safe='')}/revoke")
|
|
246
|
+
|
|
247
|
+
def list_service_accounts(self) -> dict[str, Any]:
|
|
248
|
+
return self._get("/cli/service-accounts")
|
|
249
|
+
|
|
250
|
+
def create_service_account(self, name: str) -> dict[str, Any]:
|
|
251
|
+
return self._post("/cli/service-accounts", {"name": name})
|
|
252
|
+
|
|
253
|
+
def delete_service_account(self, account_id: str) -> dict[str, Any]:
|
|
254
|
+
return self._delete(f"/cli/service-accounts/{urllib.parse.quote(account_id, safe='')}")
|
|
255
|
+
|
|
256
|
+
def list_members(self) -> dict[str, Any]:
|
|
257
|
+
return self._get("/cli/members")
|
|
258
|
+
|
|
259
|
+
def get_member(self, actor_id: str) -> dict[str, Any]:
|
|
260
|
+
return self._get(f"/cli/members/{urllib.parse.quote(actor_id, safe='')}")
|
|
261
|
+
|
|
262
|
+
def invite_member(self, email: str, role: str) -> dict[str, Any]:
|
|
263
|
+
return self._post("/cli/members/invite", {"email": email, "role": role})
|
|
264
|
+
|
|
265
|
+
def set_member_role(self, actor_id: str, role: str) -> dict[str, Any]:
|
|
266
|
+
return self._post(
|
|
267
|
+
f"/cli/members/{urllib.parse.quote(actor_id, safe='')}/role", {"role": role}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def list_activity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
271
|
+
return self._get("/cli/activity", params)
|
|
272
|
+
|
|
273
|
+
def list_failure_groups(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
274
|
+
return self._get("/cli/failure-groups", params)
|
|
275
|
+
|
|
276
|
+
def list_issues(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
277
|
+
return self._get("/cli/issues", params)
|
|
278
|
+
|
|
279
|
+
def get_issue(self, issue: str) -> dict[str, Any]:
|
|
280
|
+
return self._get(f"/cli/issues/{urllib.parse.quote(issue, safe='')}")
|
|
281
|
+
|
|
282
|
+
def set_issue_status(self, issue: str, status: str, note: str | None = None) -> dict[str, Any]:
|
|
283
|
+
return self._post(
|
|
284
|
+
f"/cli/issues/{urllib.parse.quote(issue, safe='')}/status",
|
|
285
|
+
{"status": status, "note": note},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def list_alert_rules(self) -> dict[str, Any]:
|
|
289
|
+
return self._get("/cli/alert-rules")
|
|
290
|
+
|
|
291
|
+
def get_alert_rule(self, rule_id: str) -> dict[str, Any]:
|
|
292
|
+
return self._get(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
|
|
293
|
+
|
|
294
|
+
def create_alert_rule(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
295
|
+
return self._post("/cli/alert-rules", payload)
|
|
296
|
+
|
|
297
|
+
def update_alert_rule(self, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
298
|
+
return self._post(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}", payload)
|
|
299
|
+
|
|
300
|
+
def delete_alert_rule(self, rule_id: str) -> dict[str, Any]:
|
|
301
|
+
return self._delete(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
|
|
302
|
+
|
|
303
|
+
def list_alert_channels(self) -> dict[str, Any]:
|
|
304
|
+
return self._get("/cli/alert-channels")
|
|
305
|
+
|
|
306
|
+
def create_alert_channel(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
307
|
+
return self._post(f"/cli/alert-channels/{urllib.parse.quote(kind, safe='')}", payload)
|
|
308
|
+
|
|
309
|
+
def set_alert_channel_enabled(self, channel_id: str, enabled: bool) -> dict[str, Any]:
|
|
310
|
+
return self._post(
|
|
311
|
+
f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}/enabled",
|
|
312
|
+
{"enabled": enabled},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def delete_alert_channel(self, channel_id: str) -> dict[str, Any]:
|
|
316
|
+
return self._delete(f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}")
|
|
317
|
+
|
|
318
|
+
def list_git_installations(self) -> dict[str, Any]:
|
|
319
|
+
return self._get("/cli/git-installations")
|
|
320
|
+
|
|
321
|
+
def list_git_repos(self) -> dict[str, Any]:
|
|
322
|
+
return self._get("/cli/git-repos")
|
|
323
|
+
|
|
324
|
+
def link_app(self, app: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
325
|
+
return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git", payload)
|
|
326
|
+
|
|
327
|
+
def unlink_app(self, app: str) -> dict[str, Any]:
|
|
328
|
+
return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git")
|
|
329
|
+
|
|
330
|
+
def deploy_app_from_git(self, app: str, branch: str | None = None) -> dict[str, Any]:
|
|
331
|
+
body: dict[str, Any] = {}
|
|
332
|
+
if branch is not None:
|
|
333
|
+
body["branch"] = branch
|
|
334
|
+
return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/deploy", body)
|
|
335
|
+
|
|
336
|
+
def list_marketplace_apps(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
337
|
+
return self._get("/cli/marketplace-apps", params)
|
|
338
|
+
|
|
339
|
+
def get_marketplace_app(self, slug: str) -> dict[str, Any]:
|
|
340
|
+
return self._get(f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}")
|
|
341
|
+
|
|
342
|
+
def install_app(self, slug: str, environment: str | None = None) -> dict[str, Any]:
|
|
343
|
+
body: dict[str, Any] = {}
|
|
344
|
+
if environment is not None:
|
|
345
|
+
body["environment"] = environment
|
|
346
|
+
return self._post(
|
|
347
|
+
f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}/install", body
|
|
348
|
+
)
|
|
349
|
+
|
|
113
350
|
def app_status(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
114
351
|
"""Fetch the detailed lifecycle status for a single app.
|
|
115
352
|
|