doover-cli 0.5.5__tar.gz → 0.6.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.
Files changed (92) hide show
  1. {doover_cli-0.5.5 → doover_cli-0.6.2}/PKG-INFO +2 -2
  2. {doover_cli-0.5.5 → doover_cli-0.6.2}/pyproject.toml +7 -1
  3. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/__init__.py +1 -1
  4. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/apps.py +274 -75
  5. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/login.py +18 -0
  6. doover_cli-0.6.2/src/doover_cli/registry.py +373 -0
  7. doover_cli-0.6.2/src/doover_cli/utils/apps.py +663 -0
  8. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_apps.py +144 -13
  9. doover_cli-0.6.2/tests/test_discover.py +157 -0
  10. doover_cli-0.6.2/tests/test_registry.py +432 -0
  11. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_utils_apps.py +162 -34
  12. {doover_cli-0.5.5 → doover_cli-0.6.2}/uv.lock +609 -610
  13. doover_cli-0.5.5/src/doover_cli/utils/apps.py +0 -432
  14. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/CONTRIBUTING.md +0 -0
  15. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  16. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  18. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/lint-test.yml +0 -0
  20. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release-brew.yml +0 -0
  21. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release-pypi.yml +0 -0
  22. {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release.yml +0 -0
  23. {doover_cli-0.5.5 → doover_cli-0.6.2}/.gitignore +0 -0
  24. {doover_cli-0.5.5 → doover_cli-0.6.2}/.pre-commit-config.yaml +0 -0
  25. {doover_cli-0.5.5 → doover_cli-0.6.2}/AGENT.md +0 -0
  26. {doover_cli-0.5.5 → doover_cli-0.6.2}/LICENSE +0 -0
  27. {doover_cli-0.5.5 → doover_cli-0.6.2}/README.md +0 -0
  28. {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/changelog +0 -0
  29. {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/control +0 -0
  30. {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/rules +0 -0
  31. {doover_cli-0.5.5 → doover_cli-0.6.2}/docs/user_stories.md +0 -0
  32. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/agent.py +0 -0
  33. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/__init__.py +0 -0
  34. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/auth.py +0 -0
  35. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/errors.py +0 -0
  36. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/session.py +0 -0
  37. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/app_install.py +0 -0
  38. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/device.py +0 -0
  39. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/device_type.py +0 -0
  40. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/tunnel.py +0 -0
  41. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/channel.py +0 -0
  42. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/colours.py +0 -0
  43. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/config_schema.py +0 -0
  44. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/dda_logs.py +0 -0
  45. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/doover_config.py +0 -0
  46. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/grpc.py +0 -0
  47. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/__init__.py +0 -0
  48. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_base.py +0 -0
  49. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_basic.py +0 -0
  50. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_default.py +0 -0
  51. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_json.py +0 -0
  52. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/report.py +0 -0
  53. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/simulator.py +0 -0
  54. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/ui_schema.py +0 -0
  55. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/user.py +0 -0
  56. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/__init__.py +0 -0
  57. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/api.py +0 -0
  58. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/context.py +0 -0
  59. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/__init__.py +0 -0
  60. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/commands.py +0 -0
  61. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/lookup.py +0 -0
  62. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/prompting.py +0 -0
  63. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/schema.py +0 -0
  64. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/values.py +0 -0
  65. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/errors.py +0 -0
  66. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/formatters.py +0 -0
  67. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/misc.py +0 -0
  68. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/parsers.py +0 -0
  69. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/prompt.py +0 -0
  70. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/sentry.py +0 -0
  71. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/shell_commands.py +0 -0
  72. {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/state.py +0 -0
  73. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/__init__.py +0 -0
  74. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/conftest.py +0 -0
  75. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_agent.py +0 -0
  76. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_app_install.py +0 -0
  77. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_auth_integration.py +0 -0
  78. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_auth_unit.py +0 -0
  79. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_basic.py +0 -0
  80. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_commands.py +0 -0
  81. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_lookup.py +0 -0
  82. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_prompting.py +0 -0
  83. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_schema.py +0 -0
  84. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_values.py +0 -0
  85. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_default_renderer.py +0 -0
  86. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device.py +0 -0
  87. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_cli_integration.py +0 -0
  88. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_type.py +0 -0
  89. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_type_cli_integration.py +0 -0
  90. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_prompt.py +0 -0
  91. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_sentry.py +0 -0
  92. {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_user.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doover-cli
3
- Version: 0.5.5
3
+ Version: 0.6.2
4
4
  Summary: CLI for Doover
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -10,7 +10,7 @@ Requires-Dist: docker>=7.1.0
10
10
  Requires-Dist: jsf>=0.11.2
11
11
  Requires-Dist: paramiko>=3.5.1
12
12
  Requires-Dist: prompt-toolkit>=3.0.52
13
- Requires-Dist: pydoover>=1.5.1
13
+ Requires-Dist: pydoover>=1.12.0
14
14
  Requires-Dist: pytz>=2025.2
15
15
  Requires-Dist: questionary>=1.10.0
16
16
  Requires-Dist: requests>=2.32.3
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "docker>=7.1.0",
11
11
  "jsf>=0.11.2",
12
12
  "paramiko>=3.5.1",
13
- "pydoover>=1.5.1",
13
+ "pydoover>=1.12.0",
14
14
  "pytz>=2025.2",
15
15
  "questionary>=1.10.0",
16
16
  "requests>=2.32.3",
@@ -31,6 +31,7 @@ path = "src/doover_cli/__init__.py"
31
31
 
32
32
  [project.scripts]
33
33
  doover = "doover_cli:main"
34
+ docker-credential-doover = "doover_cli.registry:credential_helper"
34
35
 
35
36
  [dependency-groups]
36
37
  dev = [
@@ -40,3 +41,8 @@ dev = [
40
41
 
41
42
  [tool.ty.environment]
42
43
  python = "./.venv"
44
+
45
+ [tool.ruff.lint]
46
+ # Pin the rule set explicitly so a newer ruff release doesn't silently enable
47
+ # hundreds of new rules in CI. These are ruff's historical defaults.
48
+ select = ["E4", "E7", "E9", "F"]
@@ -1,7 +1,7 @@
1
1
  from doover_cli.renderer import Renderer
2
2
  from typing import Annotated, Optional
3
3
 
4
- __version__ = "0.5.5"
4
+ __version__ = "0.6.2"
5
5
 
6
6
  import click
7
7
  import typer
@@ -7,6 +7,8 @@ import re
7
7
  import shutil
8
8
  import socket
9
9
  import subprocess
10
+ import sys
11
+ import tempfile
10
12
  import time
11
13
  from urllib.parse import urlencode
12
14
  from pathlib import Path
@@ -26,7 +28,10 @@ import questionary
26
28
  from ..config_schema import export as export_config_command
27
29
  from ..ui_schema import export as export_ui_command
28
30
  from ..utils.api import ProfileAnnotation
31
+ from ..registry import is_doover_registry, login_for_push, publish_github_output
29
32
  from ..utils.apps import (
33
+ PACKAGE_APP_TYPES,
34
+ discover_apps,
30
35
  get_app_directory,
31
36
  call_with_uv,
32
37
  get_docker_path,
@@ -120,6 +125,20 @@ def _detect_git_commit(root_fp: Path) -> str:
120
125
  return result.stdout.strip() if result.returncode == 0 else ""
121
126
 
122
127
 
128
+ def _is_multi_platform(build_args: str) -> bool:
129
+ """Whether `build_args` asks for more than one platform.
130
+
131
+ Multi-platform changes how the image has to be built: buildx cannot load a
132
+ multi-platform result into the local daemon, so it must push directly, and
133
+ the digest has to come from buildx rather than the daemon.
134
+ """
135
+ for token in ("--platform", "--platform="):
136
+ if token in build_args:
137
+ tail = build_args.split(token, 1)[1].lstrip("= ").split()[0]
138
+ return "," in tail
139
+ return False
140
+
141
+
123
142
  def _build_container(
124
143
  root_fp: Path, *, buildx: bool, build_args: str, image_name: str
125
144
  ) -> None:
@@ -128,6 +147,29 @@ def _build_container(
128
147
  )
129
148
 
130
149
 
150
+ def _build_and_push_multi_platform(
151
+ root_fp: Path, *, build_args: str, image_name: str
152
+ ) -> str | None:
153
+ """Build and push a multi-platform image in one step, returning its digest.
154
+
155
+ A multi-platform build cannot be loaded into the local daemon, so `build`
156
+ then `push` does not work -- and `docker inspect` afterwards reports either
157
+ nothing or the single-platform digest, not the index. buildx writes the real
158
+ digest to a metadata file, which is the only reliable source.
159
+ """
160
+ with tempfile.TemporaryDirectory() as tmp:
161
+ metadata_fp = Path(tmp) / "metadata.json"
162
+ shell_run(
163
+ f"docker buildx build {build_args} --push "
164
+ f"--metadata-file {metadata_fp} -t {image_name} {str(root_fp)}",
165
+ )
166
+ try:
167
+ metadata = json.loads(metadata_fp.read_text())
168
+ except (OSError, ValueError):
169
+ return None
170
+ return metadata.get("containerimage.digest")
171
+
172
+
131
173
  def _push_container(image_name: str) -> None:
132
174
  shell_run(f"docker push {image_name}")
133
175
 
@@ -177,34 +219,36 @@ def _build_application_payload(
177
219
  payload = app_config.to_request_payload(
178
220
  include_deployment_data=include_deployment_data,
179
221
  is_staging=staging,
180
- method="POST",
181
222
  )
182
223
 
183
- _require_publish_value("name", payload["name"])
184
- _require_publish_value("display_name", payload["display_name"])
185
- _require_publish_value("description", payload["description"])
186
- _require_publish_value("type", payload["type"])
187
- _require_publish_value("visibility", payload["visibility"])
188
- _require_publish_value(
189
- "organisation_id", payload["organisation_id"], allow_none=True
190
- )
191
- _require_publish_value(
192
- "container_registry_profile_id",
193
- payload["container_registry_profile_id"],
194
- allow_none=True if payload["type"] != "DEV" else False,
195
- )
196
- _require_publish_value("depends_on", payload["depends_on"], allow_empty_list=True)
224
+ # `name` is the only field the CLI insists on -- it is what the control plane
225
+ # upserts on, so without it there is nothing to publish to.
226
+ if not payload.get("name"):
227
+ raise typer.BadParameter("name is required in doover_config.json.")
228
+
229
+ # Everything else is validated only if the file declares it: an omitted field
230
+ # is not an error, it just isn't part of this patch, and the control plane
231
+ # still rejects a *create* that is missing something it requires. A FIX-ME is
232
+ # always caught, since that is never a deliberate value.
233
+ for field, kwargs in (
234
+ ("name", {}),
235
+ ("display_name", {}),
236
+ ("description", {}),
237
+ ("type", {}),
238
+ ("visibility", {}),
239
+ ("organisation_id", {"allow_none": True}),
240
+ ("container_registry_profile_id", {"allow_none": True}),
241
+ ("depends_on", {"allow_empty_list": True}),
242
+ ):
243
+ if field in payload:
244
+ _require_publish_value(field, payload[field], **kwargs)
197
245
 
198
246
  if include_deployment_data is False:
199
247
  payload.pop("deployment_data", None)
200
248
 
201
- return {
202
- key: value
203
- for key, value in payload.items()
204
- if value is not None
205
- or key == "organisation_id"
206
- or key == "container_registry_profile_id"
207
- }
249
+ # No None-stripping here: a key only reaches this point because the config
250
+ # declared it, and declaring `"organisation_id": null` means clear it.
251
+ return payload
208
252
 
209
253
 
210
254
  def _should_export_ui(app_config) -> bool:
@@ -222,18 +266,15 @@ def _get_persisted_application_id(app_config, *, staging: bool) -> int | None:
222
266
  return int(app_id) if app_id is not None else None
223
267
 
224
268
 
225
- def _persist_application_id(app_config, *, staging: bool, application_id: int) -> None:
226
- if staging:
227
- app_config.staging_config["id"] = application_id
228
- else:
229
- app_config.id = application_id
230
-
231
- app_config.save_to_disk()
269
+ def _resolve_application_id(client, app_config, *, staging: bool) -> int | None:
270
+ """The application's id, from doover_config.json if it is pinned there, else
271
+ looked up by name.
232
272
 
233
-
234
- def _resolve_existing_application_id(
235
- client, app_config, *, staging: bool
236
- ) -> int | None:
273
+ Application names are globally unique, so the name identifies the app on its
274
+ own and new projects need no id in their config at all. Older configs still
275
+ pin one; that wins, so renaming `name` there keeps renaming the app instead
276
+ of forking a new one.
277
+ """
237
278
  app_id = _get_persisted_application_id(app_config, staging=staging)
238
279
  if app_id is not None:
239
280
  return app_id
@@ -248,15 +289,13 @@ def _resolve_existing_application_id(
248
289
  item for item in page.results if getattr(item, "name", None) == app_config.name
249
290
  ]
250
291
  if len(matches) > 1:
292
+ # Shouldn't happen — the control plane enforces name uniqueness — but a
293
+ # silent wrong pick here would publish over the wrong app.
251
294
  raise typer.BadParameter(
252
295
  f"Multiple applications found matching name '{app_config.name}'. Set the application id in doover_config.json."
253
296
  )
254
297
  if len(matches) == 1:
255
- application_id = int(matches[0].id)
256
- _persist_application_id(
257
- app_config, staging=staging, application_id=application_id
258
- )
259
- return application_id
298
+ return int(matches[0].id)
260
299
  return None
261
300
 
262
301
 
@@ -784,10 +823,12 @@ def put_widget(
784
823
  client, renderer = get_state()
785
824
 
786
825
  resolved_staging = _resolve_staging(staging)
787
- application_id = _get_persisted_application_id(app_config, staging=resolved_staging)
826
+ application_id = _resolve_application_id(
827
+ client, app_config, staging=resolved_staging
828
+ )
788
829
  if application_id is None:
789
830
  rich.print(
790
- "[red]No application ID found in doover_config.json. Publish the app first.[/red]"
831
+ f"[red]No application named '{app_config.name}' found. Publish the app first.[/red]"
791
832
  )
792
833
  raise typer.Exit(1)
793
834
 
@@ -926,7 +967,13 @@ def publish(
926
967
  ] = Path(),
927
968
  build_container: Annotated[
928
969
  bool,
929
- typer.Option(help="Build and push the container image to the registry."),
970
+ typer.Option(
971
+ # `--build` is the name this is known by; the longer form is kept so
972
+ # existing scripts and CI keep working.
973
+ "--build/--no-build",
974
+ "--build-container/--no-build-container",
975
+ help="Build and push the container image to the registry.",
976
+ ),
930
977
  ] = False,
931
978
  staging: Annotated[
932
979
  bool | None,
@@ -969,9 +1016,33 @@ def publish(
969
1016
  release: Annotated[
970
1017
  bool,
971
1018
  typer.Option(
972
- help="Release package after building. CI/CD will manually handle this step, so should specify --no-release.",
1019
+ help="Release the application after publishing. Disable with --no-release.",
973
1020
  ),
974
1021
  ] = True,
1022
+ digest: Annotated[
1023
+ str | None,
1024
+ typer.Option(
1025
+ help="Pushed image digest (e.g. sha256:…) to use for the release. "
1026
+ "For container builds this overrides the automatically detected digest."
1027
+ ),
1028
+ ] = None,
1029
+ tag: Annotated[
1030
+ str | None,
1031
+ typer.Option(help="Optional version label (e.g. semver / image tag)."),
1032
+ ] = None,
1033
+ commit: Annotated[
1034
+ str | None,
1035
+ typer.Option(
1036
+ help="Source git commit SHA for the release. Defaults to the repo's HEAD."
1037
+ ),
1038
+ ] = None,
1039
+ notes: Annotated[str, typer.Option(help="Optional release notes.")] = "",
1040
+ alpha: Annotated[
1041
+ bool,
1042
+ typer.Option(
1043
+ help="Publish the release as a prerelease that is not auto-selected as 'Latest'."
1044
+ ),
1045
+ ] = False,
975
1046
  app_name: Annotated[
976
1047
  str | None,
977
1048
  typer.Option(
@@ -1049,29 +1120,24 @@ def publish(
1049
1120
 
1050
1121
  try:
1051
1122
  with renderer.loading("Publishing application..."):
1052
- # application_id = _resolve_existing_application_id(
1053
- # client,
1054
- # app_config,
1055
- # staging=resolved_staging,
1056
- # )
1057
1123
  application_id = _get_persisted_application_id(
1058
1124
  app_config, staging=resolved_staging
1059
1125
  )
1060
- if application_id is None:
1061
- print(json.dumps(payload, indent=4))
1062
- created = client.applications.create(body=dict(payload))
1063
- application_id = int(created.id)
1064
- _persist_application_id(
1065
- app_config,
1066
- staging=resolved_staging,
1067
- application_id=application_id,
1126
+ if application_id is not None:
1127
+ # Config pins an id — update that app directly, so changing
1128
+ # `name` renames it rather than creating a second app.
1129
+ response = client.applications.partial(
1130
+ str(application_id),
1131
+ body=dict(payload),
1068
1132
  )
1069
- print(f"Created new application with id: {application_id}")
1070
-
1071
- response = client.applications.partial(
1072
- str(application_id),
1073
- body=dict(payload),
1074
- )
1133
+ else:
1134
+ # No id: POST and let the control plane upsert on the globally
1135
+ # unique name — creating the app if the name is free, updating it
1136
+ # if we're allowed to. Nothing is written back to
1137
+ # doover_config.json, so the repo stays the source of truth and a
1138
+ # fresh clone publishes identically.
1139
+ response = client.applications.create(body=dict(payload))
1140
+ application_id = int(response.id)
1075
1141
  except typer.Exit:
1076
1142
  raise
1077
1143
  except click.Abort:
@@ -1105,7 +1171,7 @@ def publish(
1105
1171
  )
1106
1172
  rich.print("[green]Widget uploaded.[/green]")
1107
1173
 
1108
- if app_config.type in ("PRO", "REP", "INT"):
1174
+ if app_config.type in PACKAGE_APP_TYPES:
1109
1175
  if build_package:
1110
1176
  print("\nBuilding package.zip for upload...")
1111
1177
  shell_run("./build.sh", cwd=root_fp)
@@ -1125,6 +1191,11 @@ def publish(
1125
1191
  ctx.invoke(
1126
1192
  release_command,
1127
1193
  app_fp=root_fp,
1194
+ digest=digest,
1195
+ tag=tag,
1196
+ commit=commit,
1197
+ notes=notes,
1198
+ alpha=alpha,
1128
1199
  staging=staging,
1129
1200
  app_name=app_name,
1130
1201
  )
@@ -1135,28 +1206,68 @@ def publish(
1135
1206
  )
1136
1207
  raise typer.Exit(0)
1137
1208
 
1138
- image_digest: str | None = None
1209
+ detected_digest: str | None = None
1139
1210
  if build_container:
1140
1211
  image_name = _require_publish_value("image_name", payload.get("image_name"))
1141
1212
  build_args = getattr(app_config, "build_args", "") or ""
1142
1213
  if build_args != "NO_BUILD":
1143
1214
  print("\nBuilding and pushing container image to the registry...")
1144
- _build_container(
1145
- root_fp,
1146
- buildx=buildx,
1147
- build_args=build_args,
1148
- image_name=image_name,
1149
- )
1150
- _push_container(image_name)
1151
- image_digest = _get_image_digest(image_name)
1215
+
1216
+ # Pushing to the doover registry needs a credential scoped to this
1217
+ # app's repository, minted against the publish permission we just
1218
+ # exercised. Other registries keep using whatever docker login the
1219
+ # user already has.
1220
+ if is_doover_registry(image_name, _control_base_url()):
1221
+ # `response` is an Application model on create/partial; fall back to
1222
+ # resolving by name for the paths that don't return one.
1223
+ app_id = getattr(response, "id", None)
1224
+ if app_id is None and isinstance(response, dict):
1225
+ app_id = response.get("id")
1226
+ if app_id is None:
1227
+ app_id = _resolve_application_id(
1228
+ client, app_config, staging=resolved_staging
1229
+ )
1230
+ if not app_id:
1231
+ raise typer.BadParameter(
1232
+ "Could not determine the application id to mint a registry "
1233
+ "credential. Publish the app first."
1234
+ )
1235
+ login_for_push(client, app_id)
1236
+
1237
+ if _is_multi_platform(build_args):
1238
+ # buildx pushes as part of the build here; see the helper for why
1239
+ # build-then-push cannot work for multi-platform.
1240
+ detected_digest = _build_and_push_multi_platform(
1241
+ root_fp, build_args=build_args, image_name=image_name
1242
+ )
1243
+ else:
1244
+ _build_container(
1245
+ root_fp,
1246
+ buildx=buildx,
1247
+ build_args=build_args,
1248
+ image_name=image_name,
1249
+ )
1250
+ _push_container(image_name)
1251
+ detected_digest = _get_image_digest(image_name)
1252
+
1253
+ if detected_digest is None:
1254
+ print(
1255
+ "Warning: could not determine the pushed image digest. "
1256
+ "Pass --digest to release against it explicitly."
1257
+ )
1152
1258
  else:
1153
1259
  print("App requested to not build. Skipping build step.")
1154
1260
 
1155
- if release and image_digest is not None:
1261
+ release_digest = digest or detected_digest
1262
+ if release and release_digest is not None:
1156
1263
  ctx.invoke(
1157
1264
  release_command,
1158
1265
  app_fp=root_fp,
1159
- digest=image_digest,
1266
+ digest=release_digest,
1267
+ tag=tag,
1268
+ commit=commit,
1269
+ notes=notes,
1270
+ alpha=alpha,
1160
1271
  staging=staging,
1161
1272
  app_name=app_name,
1162
1273
  )
@@ -1165,6 +1276,89 @@ def publish(
1165
1276
  renderer.render(response)
1166
1277
 
1167
1278
 
1279
+ @app.command()
1280
+ def discover(
1281
+ root: Annotated[Path, typer.Argument(help="Repository root to search.")] = Path(),
1282
+ as_json: Annotated[
1283
+ bool,
1284
+ typer.Option(
1285
+ "--json",
1286
+ help="Emit a single-line JSON array, suitable for a CI matrix.",
1287
+ ),
1288
+ ] = False,
1289
+ ):
1290
+ """List every application in this repository and how to build it.
1291
+
1292
+ Handles both shapes a repo can take: several app entries in one
1293
+ doover_config.json, and several self-contained app directories each with their
1294
+ own. CI reads this to build its matrix instead of the workflow hard-coding app
1295
+ names, which is what lets one workflow file serve every app repo.
1296
+ """
1297
+ found = discover_apps(root)
1298
+ if not found:
1299
+ print(
1300
+ f"No applications found under {root}. Each app needs a "
1301
+ f"doover_config.json with a `type` set.",
1302
+ file=sys.stderr,
1303
+ )
1304
+ raise typer.Exit(1)
1305
+
1306
+ if as_json:
1307
+ # Compact and on one line so it can be captured straight into a step output.
1308
+ print(json.dumps(found, separators=(",", ":")))
1309
+ raise typer.Exit(0)
1310
+
1311
+ for entry in found:
1312
+ bits = [entry["type"] or "?", entry["language"] or "unknown language"]
1313
+ if entry["widget"]:
1314
+ bits.append("widget")
1315
+ print(f"{entry['name']:<40} {entry['dir']:<24} {', '.join(bits)}")
1316
+
1317
+
1318
+ @app.command(name="registry-login")
1319
+ def registry_login(
1320
+ app_fp: Annotated[
1321
+ Path, typer.Argument(help="Path to the application directory.")
1322
+ ] = Path(),
1323
+ app_name: Annotated[
1324
+ str | None,
1325
+ typer.Option(help="Which app in doover_config.json, if it defines several."),
1326
+ ] = None,
1327
+ staging: Annotated[
1328
+ bool | None,
1329
+ typer.Option(help="Force staging mode. Defaults to matching the API URL."),
1330
+ ] = None,
1331
+ ):
1332
+ """`docker login` to the Doover registry for this application.
1333
+
1334
+ For CI, which builds with buildx rather than `publish --build` and so needs
1335
+ the credential in place beforehand. The token is scoped to this application's
1336
+ repository only, and is piped straight into docker -- it is never printed, so
1337
+ it cannot end up in a workflow log or a step output.
1338
+
1339
+ Publish the app first: the credential is minted against its publish
1340
+ permission, and the repository comes from its registered image name.
1341
+ """
1342
+ client, _ = get_state()
1343
+ root_fp = get_app_directory(app_fp)
1344
+ app_config = get_app_config(root_fp, app_name=app_name)
1345
+
1346
+ app_id = _resolve_application_id(
1347
+ client, app_config, staging=_resolve_staging(staging)
1348
+ )
1349
+ if app_id is None:
1350
+ raise typer.BadParameter(
1351
+ f"'{app_config.name}' does not exist yet. Run `doover app publish` "
1352
+ f"before logging in to the registry."
1353
+ )
1354
+
1355
+ target = login_for_push(client, app_id)
1356
+ # Emitted so the build step can tag from the app's registered image name
1357
+ # instead of the workflow repeating it.
1358
+ publish_github_output(target)
1359
+ print(f"Logged in to push {target}")
1360
+
1361
+
1168
1362
  @app.command(name="release")
1169
1363
  def release_command(
1170
1364
  app_fp: Annotated[
@@ -1215,7 +1409,13 @@ def release_command(
1215
1409
  app_config = get_app_config(root_fp, app_name=app_name)
1216
1410
  resolved_staging = _resolve_staging(staging)
1217
1411
 
1218
- application_id = _get_persisted_application_id(app_config, staging=resolved_staging)
1412
+ client, renderer = get_state()
1413
+
1414
+ # `release` runs as its own process (CI invokes it after `publish`), so it
1415
+ # resolves the id itself — by name when the config doesn't pin one.
1416
+ application_id = _resolve_application_id(
1417
+ client, app_config, staging=resolved_staging
1418
+ )
1219
1419
  if application_id is None:
1220
1420
  rich.print(
1221
1421
  "[red]Application has not been published yet. Run `doover app publish` first.[/red]"
@@ -1230,7 +1430,6 @@ def release_command(
1230
1430
 
1231
1431
  resolved_commit = commit if commit is not None else _detect_git_commit(root_fp)
1232
1432
 
1233
- client, renderer = get_state()
1234
1433
  with renderer.loading("Creating application version..."):
1235
1434
  version = client.create_application_version(
1236
1435
  application_id,
@@ -5,6 +5,8 @@ from typer import Typer
5
5
 
6
6
  from doover_cli.api import DooverCLIAuthClient
7
7
 
8
+ from .registry import register_credential_helper, registry_host
9
+
8
10
  from .utils.sentry import capture_handled_exception
9
11
  from .utils.state import state
10
12
 
@@ -47,3 +49,19 @@ def login(
47
49
  print(
48
50
  f"Successfully logged into Doover ({environment}). You can now run `doover ... --profile {profile_name}`."
49
51
  )
52
+
53
+ # Point docker at our credential helper so `docker pull`/`push` against the
54
+ # doover registry just work, with no `docker login` and no credentials
55
+ # written to disk. Best-effort: failing to edit the user's docker config is
56
+ # not a reason to fail the login.
57
+ try:
58
+ # Register the registry that belongs to the environment just logged into,
59
+ # so a staging login makes staging images pullable rather than production's.
60
+ if register_credential_helper(registry_host(auth.control_base_url)):
61
+ print(
62
+ "Configured docker to use your Doover login for "
63
+ f"{registry_host(auth.control_base_url)} images."
64
+ )
65
+ except Exception:
66
+ if state.debug:
67
+ raise