urun-cli 0.5.2__tar.gz → 0.5.3__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.
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3
4
+
5
+ - Publish the previously-unreleased `urun app purge` and `urun build purge`
6
+ subcommands. Both landed on `main` after the `v0.5.2` tag was cut, and the
7
+ release workflow skips re-publishing when the `pyproject` version is unchanged,
8
+ so they were never available on PyPI. This is a version-bump-only release that
9
+ carries the existing, already-tested commands to PyPI so CI/operators can run
10
+ `uvx --from urun-cli==0.5.3 urun app purge <slug>` (hard-delete orphaned app
11
+ metadata after `urun app delete`) without a private git install. No CLI
12
+ behavior changes beyond making these commands installable.
13
+
3
14
  ## Unreleased
4
15
 
5
16
  - Make `urun deploy` resilient to transient network blips. The manifest/blob
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.5.2
3
+ Version: 0.5.3
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
@@ -51,21 +51,26 @@ uvx urun-cli --version
51
51
 
52
52
  ## Quick start
53
53
 
54
- Today, an operator manually vends an org-scoped deploy API key. Save it locally
55
- with `urun login`:
54
+ Save an org-scoped deploy API key locally with `urun login`:
56
55
 
57
56
  ```bash
58
- urun login --api-key urun_<32hex>
57
+ urun login
59
58
  ```
60
59
 
61
- `urun login` verifies the key with the urun API and stores credentials for later
62
- commands. The future browser-based login flow is not available in this CLI
63
- release.
60
+ If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
61
+ keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
62
+ with the urun API, and stores credentials for later commands.
63
+
64
+ If you already have a key, you can pass it directly:
65
+
66
+ ```bash
67
+ urun login --api-key urun_sk_<secret>
68
+ ```
64
69
 
65
70
  For CI or one-off commands, you can still use the environment variable:
66
71
 
67
72
  ```bash
68
- export URUN_API_KEY=urun_<32hex>
73
+ export URUN_API_KEY=urun_sk_<secret>
69
74
  ```
70
75
 
71
76
  Create `app.py`:
@@ -29,21 +29,26 @@ uvx urun-cli --version
29
29
 
30
30
  ## Quick start
31
31
 
32
- Today, an operator manually vends an org-scoped deploy API key. Save it locally
33
- with `urun login`:
32
+ Save an org-scoped deploy API key locally with `urun login`:
34
33
 
35
34
  ```bash
36
- urun login --api-key urun_<32hex>
35
+ urun login
37
36
  ```
38
37
 
39
- `urun login` verifies the key with the urun API and stores credentials for later
40
- commands. The future browser-based login flow is not available in this CLI
41
- release.
38
+ If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
39
+ keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
40
+ with the urun API, and stores credentials for later commands.
41
+
42
+ If you already have a key, you can pass it directly:
43
+
44
+ ```bash
45
+ urun login --api-key urun_sk_<secret>
46
+ ```
42
47
 
43
48
  For CI or one-off commands, you can still use the environment variable:
44
49
 
45
50
  ```bash
46
- export URUN_API_KEY=urun_<32hex>
51
+ export URUN_API_KEY=urun_sk_<secret>
47
52
  ```
48
53
 
49
54
  Create `app.py`:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -180,6 +180,11 @@ class ApiClient:
180
180
  f"/cli/builds/{urllib.parse.quote(build_id, safe='')}/logs", {"tail": tail}
181
181
  )
182
182
 
183
+ def purge_build(self, manifest_hash_or_prefix: str) -> dict[str, Any]:
184
+ return self._delete(
185
+ f"/cli/builds/{urllib.parse.quote(manifest_hash_or_prefix, safe='')}/purge"
186
+ )
187
+
183
188
  def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
184
189
  return self._get("/cli/releases", {"app": app, "environment": environment})
185
190
 
@@ -399,6 +404,15 @@ class ApiClient:
399
404
  body: dict[str, Any] | None = {"environment": environment} if environment else None
400
405
  return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/activate", body)
401
406
 
407
+ def purge_app(self, app: str) -> dict[str, Any]:
408
+ """Permanently purge orphaned app metadata for a partially-deleted app.
409
+
410
+ Hits ``DELETE /cli/apps/<slug>/purge``. The backend scopes the purge to
411
+ the API key's org and refuses to act while deployment rows still exist,
412
+ so ordinary deployed apps must use lifecycle commands first.
413
+ """
414
+ return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/purge")
415
+
402
416
  def org_config(self) -> dict[str, Any]:
403
417
  return self._json("GET", "/org-config", None)
404
418
 
@@ -1,16 +1,23 @@
1
- """Thin PostgREST client for the control-plane model catalog.
1
+ """Catalog client for the control-plane model catalog.
2
2
 
3
- ``public.model_catalog`` is org-AGNOSTIC, anon-readable reference data published
4
- by a urun-infra migration (one row per ``(model_id, variant, gpu_spec)``; see
3
+ ``public.model_catalog`` is org-AGNOSTIC reference data published by a urun-infra
4
+ migration (one row per ``(model_id, variant, gpu_spec)``; see
5
5
  ``urun-infra/catalog/CONTRACT.md``). ``urun serve catalog`` enumerates it and
6
6
  ``urun serve <id>[:<variant>]`` resolves a single row to deploy.
7
7
 
8
- CANONICAL PATH (no silent fallbacks — see CLAUDE.md): the catalog is read over
9
- PostgREST at the control-plane's ``/rest/v1/model_catalog`` endpoint. The base is
10
- derived from the SAME control-plane API URL the rest of the CLI uses (the origin
11
- of ``--api-url`` / ``URUN_API_URL`` / saved login, default ``api.urun.sh``), and
12
- PostgREST is reached with the public Supabase anon key. There is exactly ONE
13
- resolution for each input; a missing anon key FAILS HARD rather than guessing.
8
+ DEFAULT PATH (no silent fallbacks — see CLAUDE.md): the catalog is read over the
9
+ uRun API's ``/catalog`` edge function, authed with ONLY the user's API key
10
+ (``Authorization: Bearer <api_key>`` — NO Supabase anon key). The base is the
11
+ SAME control-plane API URL the rest of the CLI uses (``--api-url`` /
12
+ ``URUN_API_URL`` / saved login, default ``api.urun.sh``); the request GETs
13
+ ``<api_url>/catalog`` and the JSON body is ``{"rows": [ ...model_catalog rows... ]}``.
14
+ Build this client with :meth:`CatalogClient.from_api`.
15
+
16
+ DEPRECATED PATH: the old direct-PostgREST read at
17
+ ``/rest/v1/model_catalog`` with the public Supabase anon key
18
+ (``apikey`` + ``Authorization: Bearer <anon>``, response a top-level JSON array)
19
+ stays as an explicit opt-in fallback for callers that pass an anon key or a
20
+ ``--catalog-url``. There is exactly ONE resolution for each input.
14
21
  """
15
22
 
16
23
  from __future__ import annotations
@@ -549,12 +556,18 @@ def valid_skus_for_row(row: CatalogRow) -> list[str]:
549
556
 
550
557
 
551
558
  class CatalogClient:
552
- """Reads ``public.model_catalog`` over PostgREST.
559
+ """Reads ``public.model_catalog`` over one of two transports.
553
560
 
554
- ``base_url`` is the PostgREST base (``.../rest/v1``); ``anon_key`` is the
555
- public Supabase anon key sent as the ``apikey`` header (the table grants
556
- SELECT to ``anon``). Both are resolved by the CLI's single canonical path
557
- (see :func:`urun.serve.resolve_catalog_client`).
561
+ DEFAULT (``mode="api"``, built via :meth:`from_api`): GET ``<api_url>/catalog``
562
+ with ONLY the user's API key (``Authorization: Bearer <api_key>`` — no
563
+ ``apikey`` header). The edge function returns ``{"rows": [ ...catalog rows... ]}``.
564
+
565
+ DEPRECATED (``mode="postgrest"``, the raw ``__init__``): GET the PostgREST base
566
+ (``.../rest/v1``) with the public Supabase anon key (``apikey`` + ``Bearer``
567
+ headers); the response is a top-level JSON array. Used only when a caller
568
+ explicitly opts in with an anon key or a ``--catalog-url``.
569
+
570
+ Both are resolved by :func:`urun.serve.resolve_catalog_client`.
558
571
  """
559
572
 
560
573
  def __init__(
@@ -565,21 +578,69 @@ class CatalogClient:
565
578
  backoff: BackoffPolicy = DEPLOY_BACKOFF,
566
579
  on_retry: Callable[[str], None] | None = None,
567
580
  ):
581
+ """Build a DEPRECATED PostgREST/anon-key client.
582
+
583
+ ``base_url`` is the PostgREST base (``.../rest/v1``); ``anon_key`` is the
584
+ public Supabase anon key. Prefer :meth:`from_api` (the default API-key
585
+ transport); this path is kept only as an explicit opt-in fallback.
586
+ """
568
587
  require_safe_url(base_url, "catalog URL")
569
588
  if not anon_key:
570
589
  raise UrunError("missing catalog anon key")
571
590
  self.base_url = base_url.rstrip("/")
572
591
  self.anon_key = anon_key
592
+ self.api_key: str | None = None
593
+ self._mode = "postgrest"
573
594
  self.timeout = timeout
574
595
  self.backoff = backoff
575
596
  self.on_retry = on_retry
576
597
 
598
+ @classmethod
599
+ def from_api(
600
+ cls,
601
+ api_url: str,
602
+ api_key: str,
603
+ timeout: float = 30.0,
604
+ backoff: BackoffPolicy = DEPLOY_BACKOFF,
605
+ on_retry: Callable[[str], None] | None = None,
606
+ ) -> CatalogClient:
607
+ """Build the DEFAULT API-key catalog client.
608
+
609
+ Targets ``<api_url rstrip '/'>/catalog`` and authenticates with ONLY the
610
+ user's API key (``Authorization: Bearer <api_key>``; no anon key). The
611
+ edge function returns ``{"rows": [ ...full model_catalog rows... ]}``.
612
+ """
613
+ base = (api_url or "").rstrip("/") + "/catalog"
614
+ require_safe_url(base, "catalog URL")
615
+ if not api_key:
616
+ raise UrunError("missing catalog API key")
617
+ self = cls.__new__(cls)
618
+ self.base_url = base
619
+ self.anon_key = ""
620
+ self.api_key = api_key
621
+ self._mode = "api"
622
+ self.timeout = timeout
623
+ self.backoff = backoff
624
+ self.on_retry = on_retry
625
+ return self
626
+
577
627
  def select_rows(self) -> list[CatalogRow]:
578
628
  """Select the full catalog, PK-ordered, as :class:`CatalogRow` objects.
579
629
 
580
- Raises a clear, actionable error when the table is not seeded yet (it
581
- ships via a urun-infra migration) instead of a raw PostgREST 404.
630
+ API mode reads the ``rows`` array from the ``{"rows": [...]}`` JSON object
631
+ the ``/catalog`` edge function returns; PostgREST mode reads a top-level
632
+ JSON array. Raises a clear, actionable error when the catalog is not
633
+ seeded yet (it ships via a urun-infra migration) instead of a raw 404.
582
634
  """
635
+ if self._mode == "api":
636
+ payload = self._get("")
637
+ if not isinstance(payload, dict):
638
+ raise UrunError("catalog response was not a JSON object with a 'rows' array")
639
+ rows = payload.get("rows")
640
+ if not isinstance(rows, list):
641
+ raise UrunError("catalog response 'rows' was not a JSON array")
642
+ return [parse_row(item) for item in rows]
643
+
583
644
  params = urllib.parse.urlencode(
584
645
  {"select": "*", "order": "model_id,variant,gpu_spec"}, safe=",:"
585
646
  )
@@ -588,17 +649,25 @@ class CatalogClient:
588
649
  raise UrunError("catalog response was not a JSON array")
589
650
  return [parse_row(item) for item in payload]
590
651
 
652
+ def _headers(self) -> dict[str, str]:
653
+ common = {
654
+ "Accept": "application/json",
655
+ "User-Agent": f"urun-cli/{__version__}",
656
+ }
657
+ if self._mode == "api":
658
+ return {**common, "Authorization": f"Bearer {self.api_key}"}
659
+ return {
660
+ **common,
661
+ "apikey": self.anon_key,
662
+ "Authorization": f"Bearer {self.anon_key}",
663
+ }
664
+
591
665
  def _get(self, path: str) -> Any:
592
666
  def attempt() -> bytes:
593
667
  req = urllib.request.Request(
594
668
  self.base_url + path,
595
669
  method="GET",
596
- headers={
597
- "apikey": self.anon_key,
598
- "Authorization": f"Bearer {self.anon_key}",
599
- "Accept": "application/json",
600
- "User-Agent": f"urun-cli/{__version__}",
601
- },
670
+ headers=self._headers(),
602
671
  )
603
672
  try:
604
673
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
@@ -611,7 +680,7 @@ class CatalogClient:
611
680
  attempt,
612
681
  policy=self.backoff,
613
682
  on_retry=self.on_retry,
614
- description=f"GET {path}",
683
+ description=f"GET {self.base_url + path}",
615
684
  )
616
685
  except (urllib.error.URLError, TimeoutError) as exc:
617
686
  reason = getattr(exc, "reason", exc)
@@ -9,6 +9,7 @@ import sys
9
9
  import urllib.parse
10
10
  import webbrowser
11
11
  from collections.abc import Callable, Sequence
12
+ from contextlib import contextmanager, redirect_stderr, redirect_stdout
12
13
  from datetime import UTC, datetime
13
14
  from pathlib import Path
14
15
  from typing import Any
@@ -34,6 +35,7 @@ CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
34
35
  MAX_TABLE_CELL_WIDTH = 80
35
36
  MAX_DETAIL_CELL_WIDTH = 96
36
37
  DEFAULT_ENVIRONMENT = "main"
38
+ DEFAULT_CONSOLE_URL = "https://app.urun.sh"
37
39
  ENVIRONMENT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$")
38
40
  ENVIRONMENT_MESSAGE = (
39
41
  "environment name must be 2-32 lowercase letters, digits or hyphens, "
@@ -149,6 +151,8 @@ def main(argv: list[str] | None = None) -> int:
149
151
  return use_command(args)
150
152
  if args.command == "app":
151
153
  return app_command(args)
154
+ if args.command == "build":
155
+ return build_command(args)
152
156
  if args.command == "serve":
153
157
  from .serve import serve_command
154
158
 
@@ -172,8 +176,8 @@ def build_parser() -> argparse.ArgumentParser:
172
176
  "login",
173
177
  help="Save manually vended deploy credentials",
174
178
  description=(
175
- "Save a manually vended URUN deploy API key. "
176
- "Browser-based login is planned but not available in this CLI release."
179
+ "Save a manually vended URUN deploy API key. If no key is provided, "
180
+ "the CLI opens the console API keys page and prompts for the key."
177
181
  ),
178
182
  )
179
183
  add_login_args(login_parser)
@@ -309,6 +313,7 @@ def build_parser() -> argparse.ArgumentParser:
309
313
  add_api_args(p)
310
314
 
311
315
  add_app_parser(sub)
316
+ add_build_parser(sub)
312
317
  add_cli_parity_parsers(sub)
313
318
  from .serve import add_serve_parser
314
319
 
@@ -571,9 +576,7 @@ def add_open_use_parsers(sub: argparse._SubParsersAction) -> None:
571
576
  open_sub = open_parser.add_subparsers(dest="open_target")
572
577
  for target in ("billing", "git-connect"):
573
578
  p = open_sub.add_parser(target, help=f"Open {target}")
574
- p.add_argument(
575
- "--console-url", default=os.getenv("URUN_CONSOLE_URL", "https://app.urun.sh")
576
- )
579
+ p.add_argument("--console-url", default=os.getenv("URUN_CONSOLE_URL", DEFAULT_CONSOLE_URL))
577
580
  use = sub.add_parser("use", help="Use a local default")
578
581
  use_sub = use.add_subparsers(dest="use_target")
579
582
  env = use_sub.add_parser("environment", help="Set default environment")
@@ -583,7 +586,7 @@ def add_open_use_parsers(sub: argparse._SubParsersAction) -> None:
583
586
  def add_app_parser(sub: argparse._SubParsersAction) -> None:
584
587
  app_parser = sub.add_parser(
585
588
  "app",
586
- help="Manage a deployed app (status, scale, delete)",
589
+ help="Manage a deployed app (status, scale, delete, purge)",
587
590
  description=(
588
591
  "Manage the lifecycle of a single deployed app in the authenticated "
589
592
  "org. The app is addressed by its slug (the name shown under APP in "
@@ -674,6 +677,29 @@ def add_app_parser(sub: argparse._SubParsersAction) -> None:
674
677
  )
675
678
  add_api_args(delete_parser)
676
679
 
680
+ purge_parser = app_sub.add_parser(
681
+ "purge",
682
+ help="Permanently purge orphaned app metadata after a partial deletion",
683
+ description=(
684
+ "Permanently purge stale app metadata and capacity snapshots for an "
685
+ "app that no longer has a deployment row. This is not the normal "
686
+ "way to take an app down — use `urun app delete` first for deployed "
687
+ "apps. The backend refuses to purge apps that still have deployment "
688
+ "rows."
689
+ ),
690
+ )
691
+ purge_parser.add_argument("app", help="App slug (as shown by `urun list apps`)")
692
+ purge_parser.add_argument(
693
+ "--yes",
694
+ "-y",
695
+ action="store_true",
696
+ help="Skip the interactive confirmation prompt",
697
+ )
698
+ purge_parser.add_argument(
699
+ "--json", action="store_true", help="Emit raw JSON instead of a formatted line"
700
+ )
701
+ add_api_args(purge_parser)
702
+
677
703
  activate_parser = app_sub.add_parser(
678
704
  "activate",
679
705
  help="Reverse a retire: bring a deleted app back online",
@@ -756,6 +782,36 @@ def add_config_parser(sub: argparse._SubParsersAction) -> None:
756
782
  set_parser.add_argument("value", help="Value to store")
757
783
 
758
784
 
785
+ def add_build_parser(sub: argparse._SubParsersAction) -> None:
786
+ build_parser = sub.add_parser(
787
+ "build",
788
+ help="Manage build artifacts",
789
+ description="Manage build artifacts that are not represented by app lifecycle commands.",
790
+ )
791
+ build_sub = build_parser.add_subparsers(dest="build_command")
792
+ purge_parser = build_sub.add_parser(
793
+ "purge",
794
+ help="Permanently purge a failed build artifact",
795
+ description=(
796
+ "Delete a failed deployment status artifact from the authenticated "
797
+ "org. Pass the full 64-character manifest hash, or a unique 12+ "
798
+ "character prefix as shown in app/build listings. Only failed "
799
+ "artifacts can be purged."
800
+ ),
801
+ )
802
+ purge_parser.add_argument("manifest", help="Manifest hash or unique 12+ char prefix")
803
+ purge_parser.add_argument(
804
+ "--yes",
805
+ "-y",
806
+ action="store_true",
807
+ help="Skip the interactive confirmation prompt",
808
+ )
809
+ purge_parser.add_argument(
810
+ "--json", action="store_true", help="Emit raw JSON instead of a formatted line"
811
+ )
812
+ add_api_args(purge_parser)
813
+
814
+
759
815
  def add_login_args(parser: argparse.ArgumentParser) -> None:
760
816
  parser.add_argument(
761
817
  "--api-url",
@@ -763,7 +819,15 @@ def add_login_args(parser: argparse.ArgumentParser) -> None:
763
819
  )
764
820
  parser.add_argument(
765
821
  "--api-key",
766
- help="Deploy API key; defaults to URUN_API_KEY, otherwise prompts interactively",
822
+ help=(
823
+ "Deploy API key; defaults to URUN_API_KEY, otherwise opens the console "
824
+ "and prompts interactively"
825
+ ),
826
+ )
827
+ parser.add_argument(
828
+ "--no-browser",
829
+ action="store_true",
830
+ help="Print the API keys URL instead of opening a browser",
767
831
  )
768
832
 
769
833
 
@@ -822,8 +886,23 @@ def login(args: argparse.Namespace) -> int:
822
886
  api_key = args.api_key or os.getenv("URUN_API_KEY")
823
887
  if not api_key:
824
888
  if not sys.stdin.isatty():
825
- raise UrunError("missing API key; pass --api-key or set URUN_API_KEY")
826
- api_key = getpass.getpass("URUN API key: ").strip()
889
+ raise UrunError(
890
+ "missing API key; set URUN_API_KEY, pass --api-key, or run `urun login` "
891
+ "in an interactive terminal"
892
+ )
893
+ console_url = api_keys_console_url()
894
+ print("No URUN_API_KEY found.")
895
+ print("Create an API key in the urun console, then paste it here.")
896
+ print(f"Console URL: {console_url}")
897
+ if args.no_browser:
898
+ print("Browser launch skipped because --no-browser was passed.")
899
+ else:
900
+ opened = open_browser(console_url)
901
+ if opened:
902
+ print("Opened the console API keys page in your browser.")
903
+ else:
904
+ print("Could not open a browser automatically; open the Console URL above.")
905
+ api_key = getpass.getpass("Paste your urun API key: ").strip()
827
906
 
828
907
  validate_api_key(api_key)
829
908
  config = ApiClient(api_url, api_key).org_config()
@@ -846,6 +925,47 @@ def login(args: argparse.Namespace) -> int:
846
925
  return 0
847
926
 
848
927
 
928
+ def api_keys_console_url() -> str:
929
+ console_url = os.getenv("URUN_CONSOLE_URL", DEFAULT_CONSOLE_URL)
930
+ url = console_url.rstrip("/") + "/settings/api-keys"
931
+ require_safe_url(url, "console URL")
932
+ return url
933
+
934
+
935
+ def open_browser(url: str) -> bool:
936
+ """Open a URL without letting browser/desktop-launcher noise pollute the prompt."""
937
+ with silence_stdio_fds():
938
+ return webbrowser.open(url)
939
+
940
+
941
+ @contextmanager
942
+ def silence_stdio_fds():
943
+ """Temporarily send child-process stdout/stderr to /dev/null.
944
+
945
+ ``contextlib.redirect_stderr`` only swaps Python's ``sys.stderr`` object; GUI
946
+ launchers and browsers spawned by ``webbrowser.open`` write to the inherited
947
+ OS file descriptors. Redirect those descriptors briefly so GTK/browser
948
+ warnings do not appear in the middle of the API-key prompt.
949
+ """
950
+ saved_stdout = None
951
+ saved_stderr = None
952
+ try:
953
+ saved_stdout = os.dup(1)
954
+ saved_stderr = os.dup(2)
955
+ with open(os.devnull, "w", encoding="utf-8") as devnull:
956
+ os.dup2(devnull.fileno(), 1)
957
+ os.dup2(devnull.fileno(), 2)
958
+ with redirect_stdout(devnull), redirect_stderr(devnull):
959
+ yield
960
+ finally:
961
+ if saved_stdout is not None:
962
+ os.dup2(saved_stdout, 1)
963
+ os.close(saved_stdout)
964
+ if saved_stderr is not None:
965
+ os.dup2(saved_stderr, 2)
966
+ os.close(saved_stderr)
967
+
968
+
849
969
  def org(args: argparse.Namespace) -> int:
850
970
  # `urun org` and `urun org id` both print just the org id. There is no other
851
971
  # `org` subcommand, so a bare `org` defaults to `id`.
@@ -874,17 +994,21 @@ def config_command(args: argparse.Namespace) -> int:
874
994
  )
875
995
  return 0
876
996
  if command == "set" and key == "default-environment":
877
- environment = validate_environment(args.value)
878
- config = load_config()
879
- config["default-environment"] = environment
880
- path = save_config(config)
881
- print(f"default-environment = {environment}")
882
- print(f"Config: {path}")
883
- return 0
997
+ return _set_default_environment(args.value)
884
998
  print("usage: urun config {get,set} default-environment [value]", file=sys.stderr)
885
999
  return 2
886
1000
 
887
1001
 
1002
+ def _set_default_environment(value: str) -> int:
1003
+ environment = validate_environment(value)
1004
+ config = load_config()
1005
+ config["default-environment"] = environment
1006
+ path = save_config(config)
1007
+ print(f"default-environment = {environment}")
1008
+ print(f"Config: {path}")
1009
+ return 0
1010
+
1011
+
888
1012
  def trust_jwk(args: argparse.Namespace) -> int:
889
1013
  jwks_url = trust_jwk_url(args)
890
1014
  validate_jwks_url(jwks_url, allow_insecure=args.allow_insecure_jwks_url)
@@ -1473,13 +1597,7 @@ def use_command(args: argparse.Namespace) -> int:
1473
1597
  if getattr(args, "use_target", None) != "environment":
1474
1598
  print("usage: urun use environment <env>", file=sys.stderr)
1475
1599
  return 2
1476
- environment = validate_environment(args.environment)
1477
- config = load_config()
1478
- config["default-environment"] = environment
1479
- path = save_config(config)
1480
- print(f"default-environment = {environment}")
1481
- print(f"Config: {path}")
1482
- return 0
1600
+ return _set_default_environment(args.environment)
1483
1601
 
1484
1602
 
1485
1603
  def list_apps(args: argparse.Namespace) -> int:
@@ -1490,6 +1608,7 @@ def list_apps(args: argparse.Namespace) -> int:
1490
1608
  empty_message="No apps found.",
1491
1609
  headers=("APP", "ENV", "FUNCTION", "COMPUTE", "STATUS", "RELEASE", "DETAIL"),
1492
1610
  rows=lambda apps: [_app_row(a) for a in apps],
1611
+ enrich=enrich_app_row,
1493
1612
  )
1494
1613
 
1495
1614
 
@@ -1501,6 +1620,7 @@ def _render_list(
1501
1620
  empty_message: str,
1502
1621
  headers: tuple[str, ...],
1503
1622
  rows: Callable[[list[dict[str, Any]]], Sequence[tuple[str, ...]]],
1623
+ enrich: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
1504
1624
  ) -> int:
1505
1625
  api_url, api_key = resolve_api_credentials(args)
1506
1626
  client = ApiClient(api_url, api_key)
@@ -1509,16 +1629,20 @@ def _render_list(
1509
1629
  if not isinstance(items, list):
1510
1630
  raise UrunError(f"server returned invalid {key} payload")
1511
1631
 
1632
+ dict_items = _validate_list_items(items, key)
1633
+ enriched_items = [enrich(item) if enrich else dict(item) for item in dict_items]
1634
+ canonical = dict(response)
1635
+ canonical[key] = enriched_items
1636
+
1512
1637
  if getattr(args, "json", False):
1513
- print(json.dumps({key: items}, indent=2, sort_keys=True))
1638
+ print(json.dumps(canonical, indent=2, sort_keys=True))
1514
1639
  return 0
1515
1640
 
1516
- if not items:
1641
+ if not enriched_items:
1517
1642
  print(empty_message)
1518
1643
  return 0
1519
1644
 
1520
- dict_items = _validate_list_items(items, key)
1521
- _print_table(headers, list(rows(dict_items)))
1645
+ _print_table(headers, list(rows(enriched_items)))
1522
1646
  return 0
1523
1647
 
1524
1648
 
@@ -1537,12 +1661,48 @@ def app_command(args: argparse.Namespace) -> int:
1537
1661
  return app_scale(args)
1538
1662
  if command in {"delete", "rm"}:
1539
1663
  return app_delete(args)
1664
+ if command == "purge":
1665
+ return app_purge(args)
1540
1666
  if command == "activate":
1541
1667
  return app_activate(args)
1542
- print("usage: urun app {status,scale,delete,activate} <app> [options]", file=sys.stderr)
1668
+ print("usage: urun app {status,scale,delete,purge,activate} <app> [options]", file=sys.stderr)
1543
1669
  return 2
1544
1670
 
1545
1671
 
1672
+ def build_command(args: argparse.Namespace) -> int:
1673
+ command = getattr(args, "build_command", None)
1674
+ if command == "purge":
1675
+ return build_purge(args)
1676
+ print("usage: urun build {purge} <manifest-hash-or-prefix> [options]", file=sys.stderr)
1677
+ return 2
1678
+
1679
+
1680
+ def build_purge(args: argparse.Namespace) -> int:
1681
+ if len(args.manifest) < 12:
1682
+ raise UrunError(
1683
+ "manifest must be a full 64-character hash or a unique 12+ character prefix"
1684
+ )
1685
+ if not getattr(args, "yes", False) and not _confirm_action(
1686
+ f"Permanently purge failed build artifact '{args.manifest}'? This cannot be undone."
1687
+ ):
1688
+ print("Aborted.")
1689
+ return 1
1690
+ response = _client(args).purge_build(args.manifest)
1691
+ if getattr(args, "json", False):
1692
+ _print_json(response)
1693
+ return 0
1694
+ build = response.get("build")
1695
+ if not isinstance(build, dict):
1696
+ raise UrunError("server returned invalid build payload")
1697
+ manifest = _short_hash(build.get("manifest_hash"))
1698
+ app = _coalesce_str(build.get("app_slug"), "-")
1699
+ environment = _coalesce_str(build.get("environment"), "-")
1700
+ print(f"Purged failed build artifact {manifest}.")
1701
+ print(f"App: {app}")
1702
+ print(f"Environment: {environment}")
1703
+ return 0
1704
+
1705
+
1546
1706
  def _app_payload(response: dict[str, Any]) -> dict[str, Any]:
1547
1707
  app = response.get("app")
1548
1708
  if not isinstance(app, dict):
@@ -1554,21 +1714,19 @@ def app_status(args: argparse.Namespace) -> int:
1554
1714
  api_url, api_key = resolve_api_credentials(args)
1555
1715
  environment = resolve_environment(args)
1556
1716
  client = ApiClient(api_url, api_key)
1557
- response = client.app_status(args.app, environment=environment)
1717
+ response = build_app_status_payload(client, args.app, environment=environment)
1558
1718
  if getattr(args, "json", False):
1559
1719
  print(json.dumps(response, indent=2, sort_keys=True))
1560
1720
  return 0
1561
1721
  app = _app_payload(response)
1562
- display_status = _derive_display_status(app)
1563
- detail = _derive_detail(app, display_status)
1564
1722
  fields = [
1565
1723
  ("App", _coalesce_str(app.get("app_slug"), args.app)),
1566
1724
  ("Name", _coalesce_str(app.get("app_name"), "-")),
1567
1725
  ("Environment", _coalesce_str(app.get("environment"), "-")),
1568
1726
  ("App status", _coalesce_str(app.get("app_status"), "-")),
1569
1727
  ("Deployment", _coalesce_str(app.get("deployment_status"), "(not deployed)")),
1570
- ("Status", display_status),
1571
- ("Detail", detail),
1728
+ ("Status", _coalesce_str(app.get("status"), _DISPLAY_UNKNOWN)),
1729
+ ("Detail", _coalesce_str(app.get("detail"), "-")),
1572
1730
  ("Desired replicas", _int_str(app.get("desired_replicas"))),
1573
1731
  ("Function", _coalesce_str(app.get("function_name"), app.get("default_function"), "-")),
1574
1732
  ("Compute", _coalesce_str(app.get("compute_shape"), "-")),
@@ -1582,6 +1740,139 @@ def app_status(args: argparse.Namespace) -> int:
1582
1740
  return 0
1583
1741
 
1584
1742
 
1743
+ def build_app_status_payload(
1744
+ client: ApiClient, app_slug: str, *, environment: str
1745
+ ) -> dict[str, Any]:
1746
+ """Return the canonical app-status payload used by JSON and human output.
1747
+
1748
+ The app-detail endpoint currently has richer identity/deployment fields than
1749
+ the apps listing, while the apps listing has the readiness/capacity fields
1750
+ needed to decide whether an active deployment is actually serving. Compose
1751
+ those backend responses once, then render every output mode from the result.
1752
+ """
1753
+ response = client.app_status(app_slug, environment=environment)
1754
+ app = _app_payload(response)
1755
+ warnings = (
1756
+ list(response.get("warnings") or []) if isinstance(response.get("warnings"), list) else []
1757
+ )
1758
+ if not _app_status_has_readiness_detail(app):
1759
+ try:
1760
+ list_row = _matching_app_list_row(client, app, app_slug, environment)
1761
+ except UrunError as exc:
1762
+ warnings.append(
1763
+ {
1764
+ "code": "app_readiness_enrichment_unavailable",
1765
+ "message": str(exc),
1766
+ }
1767
+ )
1768
+ else:
1769
+ if list_row:
1770
+ app = _merge_app_status_row(app, list_row)
1771
+ app = enrich_app_row(app)
1772
+ canonical = dict(response)
1773
+ canonical["app"] = app
1774
+ if warnings:
1775
+ canonical["warnings"] = warnings
1776
+ return canonical
1777
+
1778
+
1779
+ def _app_status_has_readiness_detail(app: dict[str, Any]) -> bool:
1780
+ build = _coalesce_str(app.get("build_status"), "").lower()
1781
+ if build in {"queued", "building", "failed"}:
1782
+ return True
1783
+ deployment = _coalesce_str(app.get("deployment_status"), "").lower()
1784
+ if deployment in {"paused", "failed"}:
1785
+ return True
1786
+ if deployment != "active":
1787
+ return False
1788
+ if app.get("function_ready") is True:
1789
+ return _is_non_bool_int(app.get("ready_gpu_units")) and _is_non_bool_int(
1790
+ app.get("allocated_gpu_units")
1791
+ )
1792
+ if app.get("function_ready") is False:
1793
+ return "ready_reason" in app
1794
+ return False
1795
+
1796
+
1797
+ def _matching_app_list_row(
1798
+ client: ApiClient, app: dict[str, Any], app_slug: str, environment: str
1799
+ ) -> dict[str, Any] | None:
1800
+ apps_response = client.list_apps()
1801
+ apps = apps_response.get("apps") or []
1802
+ if not isinstance(apps, list):
1803
+ raise UrunError("server returned invalid apps payload")
1804
+ app_id = _coalesce_str(app.get("app_id"))
1805
+ current_release_id = _coalesce_str(app.get("current_release_id"))
1806
+ manifest_hash = _coalesce_str(app.get("manifest_hash"), app.get("release_version"))
1807
+ function_name = _coalesce_str(app.get("function_name"), app.get("default_function"))
1808
+ compute_shape = _coalesce_str(app.get("compute_shape"))
1809
+ slug = _coalesce_str(app.get("app_slug"), app_slug)
1810
+
1811
+ def score(candidate: dict[str, Any]) -> int:
1812
+ candidate_env = _coalesce_str(candidate.get("environment"), DEFAULT_ENVIRONMENT)
1813
+ if candidate_env != environment:
1814
+ return -1
1815
+ candidate_slug = _coalesce_str(candidate.get("app_slug"), candidate.get("app_name"))
1816
+ if candidate_slug and candidate_slug != slug:
1817
+ return -1
1818
+ candidate_hash = _coalesce_str(
1819
+ candidate.get("manifest_hash"), candidate.get("release_version")
1820
+ )
1821
+ if manifest_hash and candidate_hash and candidate_hash != manifest_hash:
1822
+ return -1
1823
+ if function_name and candidate.get("function_name") not in {None, function_name}:
1824
+ return -1
1825
+ if compute_shape and candidate.get("compute_shape") not in {None, compute_shape}:
1826
+ return -1
1827
+ value = 1
1828
+ if app_id and candidate.get("app_id") == app_id:
1829
+ value += 8
1830
+ if current_release_id and candidate.get("release_id") == current_release_id:
1831
+ value += 4
1832
+ if manifest_hash and candidate_hash == manifest_hash:
1833
+ value += 4
1834
+ if function_name and candidate.get("function_name") == function_name:
1835
+ value += 2
1836
+ if compute_shape and candidate.get("compute_shape") == compute_shape:
1837
+ value += 2
1838
+ return value
1839
+
1840
+ best: tuple[int, dict[str, Any]] | None = None
1841
+ for candidate in apps:
1842
+ if not isinstance(candidate, dict):
1843
+ raise UrunError("server returned invalid apps payload")
1844
+ candidate_score = score(candidate)
1845
+ if candidate_score < 0:
1846
+ continue
1847
+ if best is None or candidate_score > best[0]:
1848
+ best = (candidate_score, candidate)
1849
+ return dict(best[1]) if best else None
1850
+
1851
+
1852
+ def _merge_app_status_row(app: dict[str, Any], list_row: dict[str, Any]) -> dict[str, Any]:
1853
+ merged = dict(app)
1854
+ for key, value in list_row.items():
1855
+ if key in {"app_name", "app_slug", "environment"}:
1856
+ merged.setdefault(key, value)
1857
+ elif key not in merged or merged[key] is None:
1858
+ merged[key] = value
1859
+ elif (
1860
+ key
1861
+ in {
1862
+ "build_status",
1863
+ "function_ready",
1864
+ "ready_reason",
1865
+ "ready_gpu_units",
1866
+ "allocated_gpu_units",
1867
+ "queued_requests",
1868
+ "active_sessions",
1869
+ }
1870
+ and value is not None
1871
+ ):
1872
+ merged[key] = value
1873
+ return merged
1874
+
1875
+
1585
1876
  def app_scale(args: argparse.Namespace) -> int:
1586
1877
  if args.replicas < 0:
1587
1878
  raise UrunError("--replicas must be >= 0")
@@ -1621,6 +1912,29 @@ def app_delete(args: argparse.Namespace) -> int:
1621
1912
  return 0
1622
1913
 
1623
1914
 
1915
+ def app_purge(args: argparse.Namespace) -> int:
1916
+ if not getattr(args, "yes", False) and not _confirm_action(
1917
+ f"Permanently purge orphaned app state for '{args.app}'? This cannot be undone."
1918
+ ):
1919
+ print("Aborted.")
1920
+ return 1
1921
+ api_url, api_key = resolve_api_credentials(args)
1922
+ client = ApiClient(api_url, api_key)
1923
+ response = client.purge_app(args.app)
1924
+ if getattr(args, "json", False):
1925
+ print(json.dumps(response, indent=2, sort_keys=True))
1926
+ return 0
1927
+ app = _app_payload(response)
1928
+ slug = _coalesce_str(app.get("app_slug"), args.app)
1929
+ print(f"Purged orphaned app state for {slug}.")
1930
+ deleted = app.get("deleted")
1931
+ if not isinstance(deleted, dict):
1932
+ raise UrunError("server returned invalid app purge payload")
1933
+ for key in sorted(deleted):
1934
+ print(f" {key}: {_int_str(deleted.get(key))}")
1935
+ return 0
1936
+
1937
+
1624
1938
  def app_activate(args: argparse.Namespace) -> int:
1625
1939
  api_url, api_key = resolve_api_credentials(args)
1626
1940
  environment = resolve_environment(args)
@@ -1868,8 +2182,8 @@ def _app_row(d: dict[str, Any]) -> tuple[str, str, str, str, str, str, str]:
1868
2182
  app, function, compute = _resource_identity(d)
1869
2183
  environment = _coalesce_str(d.get("environment"), "-")
1870
2184
  release = _short_hash(d.get("manifest_hash") or d.get("release_version"))
1871
- status = _derive_display_status(d)
1872
- detail = _derive_detail(d, status)
2185
+ status = _coalesce_str(d.get("status"), _DISPLAY_UNKNOWN)
2186
+ detail = _coalesce_str(d.get("detail"), "-")
1873
2187
  return (app, environment, function, compute, status, release, detail)
1874
2188
 
1875
2189
 
@@ -1894,7 +2208,11 @@ def _derive_display_status(d: dict[str, Any]) -> str:
1894
2208
  if promotion == "failed":
1895
2209
  return _DISPLAY_FAILED
1896
2210
  if promotion == "active":
1897
- return _DISPLAY_READY if d.get("function_ready") else _DISPLAY_PENDING
2211
+ if d.get("function_ready") is True:
2212
+ return _DISPLAY_READY
2213
+ if d.get("function_ready") is False:
2214
+ return _DISPLAY_PENDING
2215
+ return _DISPLAY_UNKNOWN
1898
2216
  return _DISPLAY_UNKNOWN
1899
2217
 
1900
2218
 
@@ -1904,7 +2222,7 @@ def _derive_detail(d: dict[str, Any], status: str) -> str:
1904
2222
  if status == _DISPLAY_FAILED:
1905
2223
  return _coalesce_str(_failure_summary(d), d.get("ready_reason"), "Build failed")
1906
2224
  if status == _DISPLAY_PENDING:
1907
- return _coalesce_str(d.get("ready_reason"), "no_capacity_snapshot")
2225
+ return _coalesce_str(d.get("ready_reason"), "-")
1908
2226
  if status == _DISPLAY_READY:
1909
2227
  ready = d.get("ready_gpu_units")
1910
2228
  allocated = d.get("allocated_gpu_units")
@@ -1914,6 +2232,14 @@ def _derive_detail(d: dict[str, Any], status: str) -> str:
1914
2232
  return "-"
1915
2233
 
1916
2234
 
2235
+ def enrich_app_row(row: dict[str, Any]) -> dict[str, Any]:
2236
+ enriched = dict(row)
2237
+ status = _derive_display_status(enriched)
2238
+ enriched["status"] = status
2239
+ enriched["detail"] = _derive_detail(enriched, status)
2240
+ return enriched
2241
+
2242
+
1917
2243
  def _failure_summary(source: Any) -> str:
1918
2244
  if not isinstance(source, dict):
1919
2245
  return ""
@@ -1948,6 +2274,7 @@ def list_sessions(args: argparse.Namespace) -> int:
1948
2274
  # Server returns newest-first to take the LIMIT cleanly; reverse so the
1949
2275
  # newest rows appear at the bottom of the table (tail-friendly).
1950
2276
  rows=lambda sessions: [_session_row(s, now) for s in reversed(sessions)],
2277
+ enrich=lambda session: enrich_session_row(session, now),
1951
2278
  )
1952
2279
 
1953
2280
 
@@ -1955,13 +2282,23 @@ def _session_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str,
1955
2282
  sid = _short_hash(s.get("id"))
1956
2283
  app, function, shape = _resource_identity(s)
1957
2284
  started = _format_iso_minutes(s.get("allocated_at") or s.get("created_at"))
1958
- duration = _format_duration(_session_duration_seconds(s, now))
1959
- raw_status = _coalesce_str(s.get("status"), "").lower()
1960
- state = _SESSION_STATE_DISPLAY.get(raw_status, raw_status or _DISPLAY_UNKNOWN)
1961
- detail = _coalesce_str(s.get("close_reason"), "-")
2285
+ duration = _coalesce_str(s.get("duration"), "-")
2286
+ state = _coalesce_str(s.get("state"), _DISPLAY_UNKNOWN)
2287
+ detail = _coalesce_str(s.get("detail"), "-")
1962
2288
  return (sid, app, function, shape, started, duration, state, detail)
1963
2289
 
1964
2290
 
2291
+ def enrich_session_row(s: dict[str, Any], now: datetime) -> dict[str, Any]:
2292
+ enriched = dict(s)
2293
+ duration_seconds = _session_duration_seconds(enriched, now)
2294
+ raw_status = _coalesce_str(enriched.get("status"), "").lower()
2295
+ enriched["state"] = _SESSION_STATE_DISPLAY.get(raw_status, raw_status or _DISPLAY_UNKNOWN)
2296
+ enriched["detail"] = _coalesce_str(enriched.get("close_reason"), "-")
2297
+ enriched["duration_seconds"] = int(duration_seconds) if duration_seconds is not None else None
2298
+ enriched["duration"] = _format_duration(duration_seconds)
2299
+ return enriched
2300
+
2301
+
1965
2302
  def _session_duration_seconds(s: dict[str, Any], now: datetime) -> float | None:
1966
2303
  start = _parse_iso(s.get("allocated_at")) or _parse_iso(s.get("created_at"))
1967
2304
  if start is None:
@@ -2010,18 +2347,45 @@ def list_compute(args: argparse.Namespace) -> int:
2010
2347
  key="compute",
2011
2348
  fetch=lambda client: client.list_compute(limit=getattr(args, "limit", None)),
2012
2349
  empty_message="No compute is currently provisioned.",
2013
- headers=("APP", "FUNCTION", "SHAPE", "INSTANCES", "GPU UNITS", "SESSIONS", "AGE"),
2350
+ headers=(
2351
+ "APP",
2352
+ "FUNCTION",
2353
+ "SHAPE",
2354
+ "INSTANCES",
2355
+ "GPU UNITS",
2356
+ "SESSIONS",
2357
+ "AGE",
2358
+ "REASON",
2359
+ ),
2014
2360
  rows=lambda slices: [_compute_row(s, now) for s in slices],
2361
+ enrich=lambda compute: enrich_compute_row(compute, now),
2015
2362
  )
2016
2363
 
2017
2364
 
2018
- def _compute_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str, str, str, str]:
2365
+ def _compute_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str, str, str, str, str]:
2019
2366
  app, function, shape = _resource_identity(s)
2020
- instances = _format_pair(s.get("instances_allocated"), s.get("instances_ready"))
2021
- units = _format_pair(s.get("gpu_units_allocated"), s.get("gpu_units_ready"))
2022
- sessions = _format_count(s.get("active_sessions"))
2023
- age = _format_age_seconds(_age_seconds(s.get("snapshot_observed_at"), now))
2024
- return (app, function, shape, instances, units, sessions, age)
2367
+ instances = _coalesce_str(s.get("instances"), "-")
2368
+ units = _coalesce_str(s.get("gpu_units"), "-")
2369
+ sessions = _coalesce_str(s.get("sessions"), "-")
2370
+ age = _coalesce_str(s.get("age"), "-")
2371
+ reason = _coalesce_str(s.get("reason"), s.get("ready_reason"), "-")
2372
+ return (app, function, shape, instances, units, sessions, age, reason)
2373
+
2374
+
2375
+ def enrich_compute_row(s: dict[str, Any], now: datetime) -> dict[str, Any]:
2376
+ enriched = dict(s)
2377
+ age_seconds = _age_seconds(enriched.get("snapshot_observed_at"), now)
2378
+ enriched["instances"] = _format_pair(
2379
+ enriched.get("instances_allocated"), enriched.get("instances_ready")
2380
+ )
2381
+ enriched["gpu_units"] = _format_pair(
2382
+ enriched.get("gpu_units_allocated"), enriched.get("gpu_units_ready")
2383
+ )
2384
+ enriched["sessions"] = _format_count(enriched.get("active_sessions"))
2385
+ enriched["age_seconds"] = int(age_seconds) if age_seconds is not None else None
2386
+ enriched["age"] = _format_age_seconds(age_seconds)
2387
+ enriched["reason"] = _coalesce_str(enriched.get("ready_reason"), "-")
2388
+ return enriched
2025
2389
 
2026
2390
 
2027
2391
  def _format_pair(numerator: Any, denominator: Any) -> str:
@@ -2053,8 +2417,8 @@ def _age_seconds(observed_at: Any, now: datetime) -> float | None:
2053
2417
 
2054
2418
  def _format_age_seconds(seconds: float | None) -> str:
2055
2419
  # Compact age string for snapshot freshness. The view-side staleness
2056
- # threshold (10 min) is reflected in ready_reason for machine consumers;
2057
- # this column is just a human "how fresh is the data" signal.
2420
+ # threshold (10 min) is reflected in ready_reason, which is now surfaced as
2421
+ # the human REASON column; AGE stays a human "how fresh is the data" signal.
2058
2422
  if seconds is None:
2059
2423
  return "-"
2060
2424
  total = int(seconds)
@@ -65,9 +65,23 @@ def canonical_json(data: dict[str, Any]) -> bytes:
65
65
  )
66
66
 
67
67
 
68
+ # Manifest fields that are PURELY informational metadata — they describe WHEN /
69
+ # by WHICH client a deploy happened, not WHAT is being deployed. They MUST be
70
+ # excluded from ``manifest_hash`` so the hash is a true content address: two
71
+ # deploys of an identical source tree produce an identical hash (and therefore
72
+ # the SAME server-side release — no new ``app_releases`` row, no repoint, no pod
73
+ # roll). Folding ``created_at`` (a per-deploy wall-clock timestamp) into the hash
74
+ # was the root cause of no-change redeploys minting a fresh release every time
75
+ # and rolling the runtime pod (~7 min not-ready -> "0 candidates" -> no frames).
76
+ # ``client_version`` is excluded for the same reason: a CLI upgrade must not roll
77
+ # every pod when the deployed content is byte-identical.
78
+ _NON_CONTENT_MANIFEST_FIELDS = ("manifest_hash", "created_at", "client_version")
79
+
80
+
68
81
  def manifest_hash(manifest_without_hash: dict[str, Any]) -> str:
69
82
  data = dict(manifest_without_hash)
70
- data.pop("manifest_hash", None)
83
+ for field in _NON_CONTENT_MANIFEST_FIELDS:
84
+ data.pop(field, None)
71
85
  return hashlib.sha256(canonical_json(data)).hexdigest()
72
86
 
73
87
 
@@ -25,8 +25,11 @@ deployed by this path (its deletion is a follow-up; other lanes still reference
25
25
  A user who already has a local ``_serve/app.py`` can point ``--entrypoint`` at it;
26
26
  that app is deployed in place (no shim, no per-deploy env).
27
27
 
28
- CANONICAL PATH (CLAUDE.md): one PostgREST base (control-plane API origin +
29
- ``/rest/v1``) and one anon key resolution; missing inputs FAIL HARD.
28
+ CANONICAL PATH (CLAUDE.md): the catalog is read over the uRun API's ``/catalog``
29
+ endpoint using ONLY the user's API key (``<api_url>/catalog`` with
30
+ ``Authorization: Bearer <api_key>``; the body is ``{"rows": [...]}``). The old
31
+ direct-PostgREST read with a Supabase anon key is a DEPRECATED opt-in fallback
32
+ (``--anon-key`` / ``URUN_CATALOG_ANON_KEY`` / ``--catalog-url``).
30
33
  """
31
34
 
32
35
  from __future__ import annotations
@@ -125,14 +128,18 @@ def _add_catalog_resolution_args(parser: argparse.ArgumentParser) -> None:
125
128
  parser.add_argument(
126
129
  "--catalog-url",
127
130
  help=(
128
- "PostgREST base for the catalog (default: the control-plane API origin "
129
- f"+ {POSTGREST_PREFIX}, or {CATALOG_URL_ENV})"
131
+ "DEPRECATED: PostgREST base for the catalog (e.g. the control-plane API "
132
+ f"origin + {POSTGREST_PREFIX}, or {CATALOG_URL_ENV}). The default reads the "
133
+ "uRun API /catalog endpoint with your API key; only set this to opt into "
134
+ "the legacy direct-PostgREST read."
130
135
  ),
131
136
  )
132
137
  parser.add_argument(
133
138
  "--anon-key",
134
139
  help=(
135
- f"Supabase anon key for the public catalog read (default: {' / '.join(ANON_KEY_ENVS)})"
140
+ "DEPRECATED: Supabase anon key for the legacy direct-PostgREST catalog read "
141
+ f"(or {' / '.join(ANON_KEY_ENVS)}). The default reads the uRun API /catalog "
142
+ "endpoint with your API key — no anon key needed."
136
143
  ),
137
144
  )
138
145
 
@@ -415,28 +422,43 @@ def _render_deps_literal(python_deps: list[str]) -> str:
415
422
  def resolve_catalog_client(args: argparse.Namespace) -> CatalogClient:
416
423
  """Build the catalog client by the SINGLE canonical resolution.
417
424
 
418
- Base: ``--catalog-url`` -> ``URUN_CATALOG_URL`` -> the control-plane API
419
- origin + ``/rest/v1`` (same host the CLI already uses). Anon key:
420
- ``--anon-key`` -> ``URUN_CATALOG_ANON_KEY`` -> ``URUN_SUPABASE_ANON_KEY``. A
421
- missing anon key FAILS HARD (no guessing, no silent fallback).
425
+ DEFAULT (needs ONLY the API key): when no anon key (``--anon-key`` /
426
+ ``URUN_CATALOG_ANON_KEY`` / ``URUN_SUPABASE_ANON_KEY``) AND no catalog URL
427
+ (``--catalog-url`` / ``URUN_CATALOG_URL``) is given, resolve the API URL +
428
+ API key the rest of the CLI uses and return an API-key client that GETs
429
+ ``<api_url>/catalog`` with ``Authorization: Bearer <api_key>``.
430
+
431
+ DEPRECATED opt-in (PostgREST + anon key): if an anon key OR a ``--catalog-url``
432
+ IS explicitly provided, fall back to the legacy direct-PostgREST read. Base:
433
+ ``--catalog-url`` -> ``URUN_CATALOG_URL`` -> the control-plane API origin +
434
+ ``/rest/v1``. Anon key: ``--anon-key`` -> ``URUN_CATALOG_ANON_KEY`` ->
435
+ ``URUN_SUPABASE_ANON_KEY``. A ``--catalog-url`` without an anon key FAILS HARD.
422
436
  """
423
437
  from .cli import resolve_api_credentials
424
438
 
425
- base = getattr(args, "catalog_url", None) or os.getenv(CATALOG_URL_ENV)
426
- if not base:
427
- api_url, _ = resolve_api_credentials(args)
428
- base = _postgrest_base_from_api_url(api_url)
429
-
439
+ explicit_url = getattr(args, "catalog_url", None) or os.getenv(CATALOG_URL_ENV)
430
440
  anon_key = getattr(args, "anon_key", None)
431
441
  if not anon_key:
432
442
  for env in ANON_KEY_ENVS:
433
443
  anon_key = os.getenv(env)
434
444
  if anon_key:
435
445
  break
446
+
447
+ # DEFAULT: no explicit PostgREST inputs -> read the API /catalog with the API key.
448
+ if not explicit_url and not anon_key:
449
+ api_url, api_key = resolve_api_credentials(args)
450
+ return CatalogClient.from_api(api_url, api_key)
451
+
452
+ # DEPRECATED opt-in: explicit anon key and/or --catalog-url -> direct PostgREST.
453
+ base = explicit_url
454
+ if not base:
455
+ api_url, _ = resolve_api_credentials(args)
456
+ base = _postgrest_base_from_api_url(api_url)
436
457
  if not anon_key:
437
458
  raise UrunError(
438
- "missing Supabase anon key for the catalog read; pass --anon-key or set "
439
- + " / ".join(ANON_KEY_ENVS)
459
+ "missing Supabase anon key for the deprecated PostgREST catalog read; pass "
460
+ "--anon-key or set " + " / ".join(ANON_KEY_ENVS) + " (or omit --catalog-url "
461
+ "to use the default uRun API /catalog read with your API key)"
440
462
  )
441
463
  return CatalogClient(base, anon_key)
442
464
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes