urun-cli 0.7.7__tar.gz → 0.7.8__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.
Files changed (37) hide show
  1. {urun_cli-0.7.7 → urun_cli-0.7.8}/CHANGELOG.md +25 -0
  2. {urun_cli-0.7.7 → urun_cli-0.7.8}/PKG-INFO +33 -1
  3. {urun_cli-0.7.7 → urun_cli-0.7.8}/README.md +32 -0
  4. {urun_cli-0.7.7 → urun_cli-0.7.8}/pyproject.toml +2 -1
  5. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/api.py +12 -0
  6. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/catalog.py +4 -4
  7. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/cli.py +77 -31
  8. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/deps.py +193 -28
  9. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/hub_builds.py +27 -0
  10. urun_cli-0.7.8/src/urun/kubectl_plugin.py +688 -0
  11. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/rust.py +1 -1
  12. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/serve.py +70 -1
  13. {urun_cli-0.7.7 → urun_cli-0.7.8}/.gitignore +0 -0
  14. {urun_cli-0.7.7 → urun_cli-0.7.8}/LICENSE +0 -0
  15. {urun_cli-0.7.7 → urun_cli-0.7.8}/SECURITY.md +0 -0
  16. {urun_cli-0.7.7 → urun_cli-0.7.8}/hatch_build.py +0 -0
  17. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/__init__.py +0 -0
  18. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/_bin/README.md +0 -0
  19. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/buildlogs.py +0 -0
  20. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/client_tokens.py +0 -0
  21. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/config.py +0 -0
  22. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/dev.py +0 -0
  23. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/devconsole.py +0 -0
  24. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/devstream.py +0 -0
  25. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/discovery.py +0 -0
  26. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/errors.py +0 -0
  27. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/fleet.py +0 -0
  28. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/frontend_launch.py +0 -0
  29. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/launch_auth.py +0 -0
  30. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/manifest.py +0 -0
  31. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/nodes.py +0 -0
  32. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/openai_proxy_bin.py +0 -0
  33. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/repo_deps.py +0 -0
  34. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/reserve.py +0 -0
  35. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/retry.py +0 -0
  36. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/runtime_health.py +0 -0
  37. {urun_cli-0.7.7 → urun_cli-0.7.8}/src/urun/scratch.py +0 -0
@@ -1,5 +1,30 @@
1
1
  ## Unreleased
2
2
 
3
+ - `urun release promote <release_id> [--environment main]` — make an existing
4
+ release current again (clean rollback, no marker commits). Server side it
5
+ takes the same path as a same-content `urun deploy`: the control plane
6
+ re-registers the release and republishes the release pointer.
7
+ - Deploy deps collector: `@app.function(deps=...)` may now reference a
8
+ module-level `Dependencies(...)` constant imported from a file shipped with
9
+ the app (`from sense_deploy import SENSE_DEPS`) — resolved statically by the
10
+ same side-effect-free evaluator, including re-export chains and the
11
+ `import mod` + `mod.SYM` attribute form. An attached `deps=` that cannot be
12
+ statically resolved now FAILS THE DEPLOY LOUDLY naming `file:line`, the
13
+ import it came through, and the fix — previously it silently shipped an
14
+ EMPTY manifest (no python pins, no files) and the app's boot self-test died
15
+ with `No module named ...` after fleet rollout (sense, 2026-10-27/28).
16
+ - `kubectl urun` — a kubectl plugin (`kubectl-urun`, installed alongside
17
+ `urun`) with four verbs over the `gpu.urun.sh/v1alpha1` GPUWorker surface:
18
+ `logs` (the `logs.urun.sh/v1` log subresource, with `-f` follow
19
+ passthrough, `--since`, `--tail`, `-s <service>`), `cordon`/`uncordon`
20
+ (`spec.unschedulable`), `drain` (cordon, then wait for the `Draining`
21
+ condition to reach `False`/`Drained`, nonzero on `--timeout`), and
22
+ `describe` (node block, services, conditions, events, liveness lease,
23
+ claim summary). Claim-owned workers are cordoned through their OWNING
24
+ GPUWorkerClaim (resolved via `spec.claimRef`) because the claim
25
+ reconciler re-renders the worker's spec; the patched object is printed.
26
+ All verbs ride a bounded `kubectl proxy --port=0` child, so
27
+ `--kubeconfig`/`--context`/`-n` resolve exactly as kubectl resolves them.
3
28
  - Security (ENG-570 / pentest urun-cli-01, mirrored in urun-python
4
29
  `urun/core/store/repo.py`): `store.repo(...)` specs parsed from untrusted
5
30
  app source are validated before any git call. The `@<ref>` pin must be a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.7.7
3
+ Version: 0.7.8
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
@@ -534,6 +534,33 @@ What it does:
534
534
  sooner. `--no-logs` skips streaming the occupant's log after creation
535
535
  (Ctrl-C also detaches without tearing the occupant down).
536
536
 
537
+ ## Operator: GPUWorker nodes (`kubectl urun`)
538
+
539
+ Installing `urun-cli` also installs the `kubectl-urun` binary; with it on
540
+ `PATH`, kubectl dispatches `kubectl urun` to it as a standard kubectl plugin.
541
+ These verbs are the operator surface over the `gpu.urun.sh/v1alpha1`
542
+ GPUWorker custom resources (the cluster must have the urun controller
543
+ installed); they resolve kubeconfig/context through kubectl itself, so
544
+ `--kubeconfig`, `--context`, and `-n` behave like any other kubectl command:
545
+
546
+ ```bash
547
+ kubectl urun logs <worker> -n urun # one-shot aggregated log
548
+ kubectl urun logs <worker> -n urun -s vineyard --tail 200 --since 30m
549
+ kubectl urun logs <worker> -n urun -f # follow; Ctrl-C detaches
550
+ kubectl urun cordon <worker> -n urun # mark unschedulable
551
+ kubectl urun uncordon <worker> -n urun
552
+ kubectl urun drain <worker> -n urun --timeout 300 # cordon + wait for Drained
553
+ kubectl urun describe <worker> -n urun # node/services/conditions/events/lease
554
+ ```
555
+
556
+ `cordon` sets `spec.unschedulable`. Claim-owned workers (workers with a
557
+ `spec.claimRef`) are re-rendered from their claim by the claim reconciler, so
558
+ `cordon`/`uncordon`/`drain` patch the OWNING CLAIM's `spec.unschedulable`
559
+ instead and print which object they patched; workers without a claim are
560
+ patched directly. `drain` cordons and then watches the worker's `Draining`
561
+ condition until it flips to `False`/`Drained` (no sessions left), exiting
562
+ nonzero on `--timeout` (default 300s).
563
+
537
564
  ## What gets deployed
538
565
 
539
566
  `urun deploy` creates a source manifest from your Python entrypoint:
@@ -579,6 +606,11 @@ def superres(): ...
579
606
  - `files=` is **purely additive**. With no `files=`, collection is exactly what
580
607
  it was before.
581
608
  - Entries are **individual files**, not directories or globs. List each file.
609
+ - `deps=` may be a module-level `Dependencies(...)` constant imported from a
610
+ file shipped with the app (`from deps_mod import APP_DEPS`) — the deploy
611
+ collector resolves it statically. Anything it cannot resolve (an import
612
+ outside the app directory, a non-literal) **fails the deploy loudly**, naming
613
+ `file:line` and the fix — it never ships an empty manifest.
582
614
  - A declared path that does not exist, escapes the app directory (`..`, a
583
615
  symlink pointing outside), or is absolute **fails the deploy immediately**,
584
616
  naming the path and the directory it was looked up under. A declared file is
@@ -509,6 +509,33 @@ What it does:
509
509
  sooner. `--no-logs` skips streaming the occupant's log after creation
510
510
  (Ctrl-C also detaches without tearing the occupant down).
511
511
 
512
+ ## Operator: GPUWorker nodes (`kubectl urun`)
513
+
514
+ Installing `urun-cli` also installs the `kubectl-urun` binary; with it on
515
+ `PATH`, kubectl dispatches `kubectl urun` to it as a standard kubectl plugin.
516
+ These verbs are the operator surface over the `gpu.urun.sh/v1alpha1`
517
+ GPUWorker custom resources (the cluster must have the urun controller
518
+ installed); they resolve kubeconfig/context through kubectl itself, so
519
+ `--kubeconfig`, `--context`, and `-n` behave like any other kubectl command:
520
+
521
+ ```bash
522
+ kubectl urun logs <worker> -n urun # one-shot aggregated log
523
+ kubectl urun logs <worker> -n urun -s vineyard --tail 200 --since 30m
524
+ kubectl urun logs <worker> -n urun -f # follow; Ctrl-C detaches
525
+ kubectl urun cordon <worker> -n urun # mark unschedulable
526
+ kubectl urun uncordon <worker> -n urun
527
+ kubectl urun drain <worker> -n urun --timeout 300 # cordon + wait for Drained
528
+ kubectl urun describe <worker> -n urun # node/services/conditions/events/lease
529
+ ```
530
+
531
+ `cordon` sets `spec.unschedulable`. Claim-owned workers (workers with a
532
+ `spec.claimRef`) are re-rendered from their claim by the claim reconciler, so
533
+ `cordon`/`uncordon`/`drain` patch the OWNING CLAIM's `spec.unschedulable`
534
+ instead and print which object they patched; workers without a claim are
535
+ patched directly. `drain` cordons and then watches the worker's `Draining`
536
+ condition until it flips to `False`/`Drained` (no sessions left), exiting
537
+ nonzero on `--timeout` (default 300s).
538
+
512
539
  ## What gets deployed
513
540
 
514
541
  `urun deploy` creates a source manifest from your Python entrypoint:
@@ -554,6 +581,11 @@ def superres(): ...
554
581
  - `files=` is **purely additive**. With no `files=`, collection is exactly what
555
582
  it was before.
556
583
  - Entries are **individual files**, not directories or globs. List each file.
584
+ - `deps=` may be a module-level `Dependencies(...)` constant imported from a
585
+ file shipped with the app (`from deps_mod import APP_DEPS`) — the deploy
586
+ collector resolves it statically. Anything it cannot resolve (an import
587
+ outside the app directory, a non-literal) **fails the deploy loudly**, naming
588
+ `file:line` and the fix — it never ships an empty manifest.
557
589
  - A declared path that does not exist, escapes the app directory (`..`, a
558
590
  symlink pointing outside), or is absolute **fails the deploy immediately**,
559
591
  naming the path and the directory it was looked up under. A declared file is
@@ -20,7 +20,7 @@ name = "urun-cli"
20
20
  # so every merge remains on the already-published version until this value is
21
21
  # advanced. Wiring semantic-release into CI remains separate work; this patch
22
22
  # bump is the minimal release unblock (post-v0.6.3 fixes + the Rust feature minor).
23
- version = "0.7.7"
23
+ version = "0.7.8"
24
24
  description = "End-user CLI for deploying apps to urun"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.11"
@@ -48,6 +48,7 @@ Issues = "https://github.com/urun-sh/urun-cli/issues"
48
48
  [project.scripts]
49
49
  urun = "urun.cli:main"
50
50
  urun-cli = "urun.cli:main"
51
+ kubectl-urun = "urun.kubectl_plugin:main"
51
52
 
52
53
  [project.optional-dependencies]
53
54
  # Rust app discovery (urun/rust.py) parses with the pinned native
@@ -461,6 +461,18 @@ class ApiClient:
461
461
  def get_release(self, release_id: str) -> dict[str, Any]:
462
462
  return self._get(f"/cli/releases/{urllib.parse.quote(release_id, safe='')}")
463
463
 
464
+ def promote_release(self, release_id: str, environment: str) -> dict[str, Any]:
465
+ """Make an EXISTING release current for ``environment`` (rollback).
466
+
467
+ ``POST /cli/releases/<id>/promote`` re-fires the release's deploy event —
468
+ the same path a same-content ``urun deploy`` takes — so the control plane
469
+ re-registers it and republishes the release pointer.
470
+ """
471
+ return self._post(
472
+ f"/cli/releases/{urllib.parse.quote(release_id, safe='')}/promote",
473
+ {"environment": environment},
474
+ )
475
+
464
476
  def query_logs(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
465
477
  return self._get("/cli/logs", params)
466
478
 
@@ -120,7 +120,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
120
120
  "nvidia-cuda-crt==13.0.88",
121
121
  "nvidia-cuda-cccl==13.0.85",
122
122
  "nvidia-nvvm==13.0.88",
123
- "nvidia-cuda-runtime==13.0.96",
123
+ "nvidia-cuda-runtime==13.0.*",
124
124
  "numpy<2",
125
125
  "pillow>=10.0.0",
126
126
  "orjson",
@@ -162,7 +162,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
162
162
  "nvidia-cuda-crt==13.0.88",
163
163
  "nvidia-cuda-cccl==13.0.85",
164
164
  "nvidia-nvvm==13.0.88",
165
- "nvidia-cuda-runtime==13.0.96",
165
+ "nvidia-cuda-runtime==13.0.*",
166
166
  "numpy<2",
167
167
  "pillow>=10.0.0",
168
168
  "orjson",
@@ -368,7 +368,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
368
368
  "nvidia-cuda-crt==13.0.88",
369
369
  "nvidia-cuda-cccl==13.0.85",
370
370
  "nvidia-nvvm==13.0.88",
371
- "nvidia-cuda-runtime==13.0.96",
371
+ "nvidia-cuda-runtime==13.0.*",
372
372
  "numpy<2",
373
373
  "pillow>=10.0.0",
374
374
  "orjson",
@@ -446,7 +446,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
446
446
  "nvidia-cuda-crt==13.0.88",
447
447
  "nvidia-cuda-cccl==13.0.85",
448
448
  "nvidia-nvvm==13.0.88",
449
- "nvidia-cuda-runtime==13.0.96",
449
+ "nvidia-cuda-runtime==13.0.*",
450
450
  "ninja",
451
451
  "pip",
452
452
  "nvidia-cutlass-dsl>=4.6.0.dev0,<5",
@@ -744,6 +744,14 @@ def add_session_parsers(sub: argparse._SubParsersAction) -> None:
744
744
 
745
745
  def add_secret_parsers(sub: argparse._SubParsersAction) -> None:
746
746
  parser = sub.add_parser("secret", help="Manage secrets (list, get, set, delete)")
747
+ parser.add_argument(
748
+ "--hub-url",
749
+ help=(
750
+ "Regional hub base URL; manage the hub's per-org sealed secret "
751
+ "store instead of the legacy control plane. Defaults to "
752
+ "URUN_HUB_URL; unset = the legacy path."
753
+ ),
754
+ )
747
755
  secret_sub = parser.add_subparsers(dest="secret_command")
748
756
 
749
757
  list_p = secret_sub.add_parser("list", help="List secret metadata")
@@ -802,7 +810,7 @@ def add_capacity_parsers(sub: argparse._SubParsersAction) -> None:
802
810
 
803
811
 
804
812
  def add_release_parsers(sub: argparse._SubParsersAction) -> None:
805
- parser = sub.add_parser("release", help="Manage releases (list, get)")
813
+ parser = sub.add_parser("release", help="Manage releases (list, get, promote)")
806
814
  release_sub = parser.add_subparsers(dest="release_command")
807
815
 
808
816
  list_p = release_sub.add_parser("list", help="List releases")
@@ -814,6 +822,13 @@ def add_release_parsers(sub: argparse._SubParsersAction) -> None:
814
822
  get_p.add_argument("target")
815
823
  add_json_api(get_p)
816
824
 
825
+ promote_p = release_sub.add_parser(
826
+ "promote", help="Make an existing release current (rollback / re-promote)"
827
+ )
828
+ promote_p.add_argument("target", help="Release id (see `urun release list`)")
829
+ add_environment_arg(promote_p, "Environment to promote into (default: configured, else main)")
830
+ add_json_api(promote_p)
831
+
817
832
 
818
833
  def add_environment_parsers(sub: argparse._SubParsersAction) -> None:
819
834
  parser = sub.add_parser("environment", help="Manage environments (list, get, create)")
@@ -2234,13 +2249,25 @@ def _hub_client_for_deploy(
2234
2249
  of the login, not an engineer-only flag). Unset = the legacy
2235
2250
  control-plane path, byte-for-byte unchanged.
2236
2251
  """
2252
+ hub_url = _hub_url_selected(args)
2253
+ if not hub_url:
2254
+ return None
2255
+ return HubBuildClient(hub_url, api_key, on_retry=on_retry)
2256
+
2257
+
2258
+ def _hub_url_selected(args: argparse.Namespace) -> str | None:
2259
+ """The hub URL when hub mode is selected, else None.
2260
+
2261
+ Precedence: ``--hub-url`` flag > ``URUN_HUB_URL`` env > the SAVED LOGIN's
2262
+ ``hub_url`` (written by ``urun login --hub-url``). The ONE copy of this
2263
+ order — every hub-mode client resolution (deploy, secrets) goes through
2264
+ here so none of them can drift onto a different surface.
2265
+ """
2237
2266
  hub_url = getattr(args, "hub_url", None) or os.getenv("URUN_HUB_URL")
2238
2267
  if not hub_url:
2239
2268
  saved = load_credentials().get("hub_url")
2240
2269
  hub_url = saved if isinstance(saved, str) and saved else None
2241
- if not hub_url:
2242
- return None
2243
- return HubBuildClient(hub_url, api_key, on_retry=on_retry)
2270
+ return hub_url
2244
2271
 
2245
2272
 
2246
2273
  def hub_deployment_doc(
@@ -2495,27 +2522,29 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
2495
2522
  creds_to_push: dict[str, str] = {}
2496
2523
  creds_from_store: list[str] = []
2497
2524
  declared_credential_names: list[str] = []
2498
- secret_client: ApiClient | None = None
2499
- if (required_env or optional_env) and hub is not None:
2500
- # HUB ISOLATION (owner ruling 2026-10-27): a hub deploy must never touch
2501
- # the legacy control plane — silently reading or pushing secrets to
2502
- # api_url is exactly the bug that created prod rows from hub-mode
2503
- # deploys. The hub has no secret surface yet, so fail loud; declaring
2504
- # creds on a hub deploy needs that surface first.
2505
- raise UrunError(
2506
- "deploy --hub-url cannot deliver declared Credentials(...) yet: the "
2507
- "hub has no secret surface, and pushing them to the legacy control "
2508
- "plane would silently touch prod. Remove the Credentials(...) "
2509
- "declaration, or deploy without --hub-url."
2510
- )
2525
+ secret_client: ApiClient | HubBuildClient | None = None
2511
2526
  if required_env or optional_env:
2512
- secret_client = ApiClient(api_url, api_key)
2513
- # Org-level secrets (``app_id`` null) plus this app's app-level secrets —
2514
- # the precise set the runtime injects (see control-plane urun_org_secret_env).
2515
- all_secrets = secret_client.list_secrets(None).get("secrets", [])
2516
- store_names = {s["name"] for s in all_secrets if not s.get("app_id")}
2517
- app_secrets = secret_client.list_secrets(app_name).get("secrets", [])
2518
- store_names |= {s["name"] for s in app_secrets}
2527
+ if hub is not None:
2528
+ # HUB SECRETS CONTRACT: the hub keeps a per-org sealed secret store
2529
+ # (GET/PUT/DELETE /v1/secrets on the org API key); values are
2530
+ # delivered to nodes only at claim time over the worker channel
2531
+ # (POST /v1/worker/secrets, claim-fenced). The legacy control plane
2532
+ # is never contacted in hub mode — not even constructed.
2533
+ secret_client = hub
2534
+ all_secrets = secret_client.list_secrets().get("secrets", [])
2535
+ # Same two-set union as the legacy path: org-level (``app_id`` null)
2536
+ # plus this app's app-level secrets — the precise set the runtime
2537
+ # injects (see control-plane urun_org_secret_env).
2538
+ store_names = {s["name"] for s in all_secrets if not s.get("app_id")}
2539
+ store_names |= {s["name"] for s in all_secrets if s.get("app_id") == app_name}
2540
+ else:
2541
+ secret_client = ApiClient(api_url, api_key)
2542
+ # Org-level secrets (``app_id`` null) plus this app's app-level secrets —
2543
+ # the precise set the runtime injects (see control-plane urun_org_secret_env).
2544
+ all_secrets = secret_client.list_secrets(None).get("secrets", [])
2545
+ store_names = {s["name"] for s in all_secrets if not s.get("app_id")}
2546
+ app_secrets = secret_client.list_secrets(app_name).get("secrets", [])
2547
+ store_names |= {s["name"] for s in app_secrets}
2519
2548
  creds_to_push, _missing_required, creds_from_store = resolve_declared_credentials(
2520
2549
  required_env, optional_env, store_names
2521
2550
  )
@@ -3503,6 +3532,19 @@ def _client(args: argparse.Namespace) -> ApiClient:
3503
3532
  return ApiClient(api_url, api_key)
3504
3533
 
3505
3534
 
3535
+ def _secrets_client(args: argparse.Namespace) -> ApiClient | HubBuildClient:
3536
+ """The secret-store client: the hub when hub mode is selected, else legacy.
3537
+
3538
+ A `--hub-url` on a secret command must never dial the legacy control
3539
+ plane behind it — same precedence as :func:`_hub_client_for_deploy`.
3540
+ """
3541
+ api_url, api_key = resolve_api_credentials(args)
3542
+ hub_url = _hub_url_selected(args)
3543
+ if hub_url:
3544
+ return HubBuildClient(hub_url, api_key)
3545
+ return ApiClient(api_url, api_key)
3546
+
3547
+
3506
3548
  def _print_json(payload: Any) -> None:
3507
3549
  print(json.dumps(payload, indent=2, sort_keys=True))
3508
3550
 
@@ -3528,8 +3570,9 @@ def _filter_params(args: argparse.Namespace) -> dict[str, Any]:
3528
3570
  return {k: v for k, v in params.items() if v is not None and v is not False}
3529
3571
 
3530
3572
 
3531
- def generic_list(args: argparse.Namespace, target: str) -> int:
3532
- client = _client(args)
3573
+ def generic_list(args: argparse.Namespace, target: str, client: Any = None) -> int:
3574
+ if client is None:
3575
+ client = _client(args)
3533
3576
  params = _filter_params(args)
3534
3577
  fetchers: dict[str, Callable[[], dict[str, Any]]] = {
3535
3578
  "shapes": lambda: client.list_shapes(params),
@@ -3574,7 +3617,7 @@ def _mcp_only_failure(command: str, mcp_tool: str) -> int:
3574
3617
 
3575
3618
  def _get_secret_metadata(args: argparse.Namespace) -> dict[str, Any]:
3576
3619
  """Metadata-only: the store NEVER returns secret values."""
3577
- client = _client(args)
3620
+ client = _secrets_client(args)
3578
3621
  secrets = client.list_secrets(getattr(args, "app", None)).get("secrets", [])
3579
3622
  matches = [s for s in secrets if s.get("name") == args.target]
3580
3623
  if not matches:
@@ -3614,7 +3657,7 @@ def _session_stop_command(args: argparse.Namespace) -> int:
3614
3657
  def secret_command(args: argparse.Namespace) -> int:
3615
3658
  command = getattr(args, "secret_command", None)
3616
3659
  if command == "list":
3617
- return generic_list(args, "secrets")
3660
+ return generic_list(args, "secrets", client=_secrets_client(args))
3618
3661
  if command == "get":
3619
3662
  response = _get_secret_metadata(args)
3620
3663
  return print_or_table(response, args, "secret")
@@ -3637,7 +3680,7 @@ def _secret_set_command(args: argparse.Namespace) -> int:
3637
3680
  value = getpass.getpass(f"Value for secret {args.name}: ")
3638
3681
  if value == "":
3639
3682
  raise UrunError("empty secret value from prompt; aborting")
3640
- response = _client(args).set_secret(args.name, {"value": value, "app": args.app})
3683
+ response = _secrets_client(args).set_secret(args.name, {"value": value, "app": args.app})
3641
3684
  return print_or_table(response, args, "secret")
3642
3685
 
3643
3686
 
@@ -3645,7 +3688,7 @@ def _secret_delete_command(args: argparse.Namespace) -> int:
3645
3688
  if not getattr(args, "yes", False) and not _confirm_action(f"Delete secret {args.target}?"):
3646
3689
  print("Aborted.")
3647
3690
  return 1
3648
- response = _client(args).delete_secret(args.target, app=getattr(args, "app", None))
3691
+ response = _secrets_client(args).delete_secret(args.target, app=getattr(args, "app", None))
3649
3692
  return print_or_table(response, args, "secret")
3650
3693
 
3651
3694
 
@@ -3678,7 +3721,10 @@ def release_command(args: argparse.Namespace) -> int:
3678
3721
  if command == "get":
3679
3722
  response = _client(args).get_release(args.target)
3680
3723
  return print_or_table(response, args, "release")
3681
- print("usage: urun release {list,get} [options]", file=sys.stderr)
3724
+ if command == "promote":
3725
+ response = _client(args).promote_release(args.target, resolve_environment(args))
3726
+ return print_or_table(response, args, "release")
3727
+ print("usage: urun release {list,get,promote} [options]", file=sys.stderr)
3682
3728
  return 2
3683
3729
 
3684
3730