doover-cli 0.5.4__tar.gz → 0.6.1__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 (88) hide show
  1. {doover_cli-0.5.4 → doover_cli-0.6.1}/PKG-INFO +1 -1
  2. {doover_cli-0.5.4 → doover_cli-0.6.1}/pyproject.toml +5 -0
  3. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/__init__.py +1 -1
  4. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/apps.py +145 -65
  5. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/apps.py +138 -57
  6. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_apps.py +156 -19
  7. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_utils_apps.py +76 -34
  8. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/CONTRIBUTING.md +0 -0
  9. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  10. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  12. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/lint-test.yml +0 -0
  14. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release-brew.yml +0 -0
  15. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release-pypi.yml +0 -0
  16. {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release.yml +0 -0
  17. {doover_cli-0.5.4 → doover_cli-0.6.1}/.gitignore +0 -0
  18. {doover_cli-0.5.4 → doover_cli-0.6.1}/.pre-commit-config.yaml +0 -0
  19. {doover_cli-0.5.4 → doover_cli-0.6.1}/AGENT.md +0 -0
  20. {doover_cli-0.5.4 → doover_cli-0.6.1}/LICENSE +0 -0
  21. {doover_cli-0.5.4 → doover_cli-0.6.1}/README.md +0 -0
  22. {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/changelog +0 -0
  23. {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/control +0 -0
  24. {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/rules +0 -0
  25. {doover_cli-0.5.4 → doover_cli-0.6.1}/docs/user_stories.md +0 -0
  26. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/agent.py +0 -0
  27. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/__init__.py +0 -0
  28. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/auth.py +0 -0
  29. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/errors.py +0 -0
  30. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/session.py +0 -0
  31. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/app_install.py +0 -0
  32. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/device.py +0 -0
  33. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/device_type.py +0 -0
  34. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/tunnel.py +0 -0
  35. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/channel.py +0 -0
  36. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/colours.py +0 -0
  37. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/config_schema.py +0 -0
  38. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/dda_logs.py +0 -0
  39. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/doover_config.py +0 -0
  40. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/grpc.py +0 -0
  41. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/login.py +0 -0
  42. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/__init__.py +0 -0
  43. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_base.py +0 -0
  44. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_basic.py +0 -0
  45. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_default.py +0 -0
  46. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_json.py +0 -0
  47. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/report.py +0 -0
  48. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/simulator.py +0 -0
  49. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/ui_schema.py +0 -0
  50. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/user.py +0 -0
  51. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/__init__.py +0 -0
  52. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/api.py +0 -0
  53. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/context.py +0 -0
  54. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/__init__.py +0 -0
  55. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/commands.py +0 -0
  56. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/lookup.py +0 -0
  57. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/prompting.py +0 -0
  58. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/schema.py +0 -0
  59. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/values.py +0 -0
  60. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/errors.py +0 -0
  61. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/formatters.py +0 -0
  62. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/misc.py +0 -0
  63. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/parsers.py +0 -0
  64. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/prompt.py +0 -0
  65. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/sentry.py +0 -0
  66. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/shell_commands.py +0 -0
  67. {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/state.py +0 -0
  68. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/__init__.py +0 -0
  69. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/conftest.py +0 -0
  70. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_agent.py +0 -0
  71. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_app_install.py +0 -0
  72. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_auth_integration.py +0 -0
  73. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_auth_unit.py +0 -0
  74. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_basic.py +0 -0
  75. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_commands.py +0 -0
  76. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_lookup.py +0 -0
  77. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_prompting.py +0 -0
  78. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_schema.py +0 -0
  79. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_values.py +0 -0
  80. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_default_renderer.py +0 -0
  81. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device.py +0 -0
  82. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_cli_integration.py +0 -0
  83. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_type.py +0 -0
  84. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_type_cli_integration.py +0 -0
  85. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_prompt.py +0 -0
  86. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_sentry.py +0 -0
  87. {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_user.py +0 -0
  88. {doover_cli-0.5.4 → doover_cli-0.6.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doover-cli
3
- Version: 0.5.4
3
+ Version: 0.6.1
4
4
  Summary: CLI for Doover
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -40,3 +40,8 @@ dev = [
40
40
 
41
41
  [tool.ty.environment]
42
42
  python = "./.venv"
43
+
44
+ [tool.ruff.lint]
45
+ # Pin the rule set explicitly so a newer ruff release doesn't silently enable
46
+ # hundreds of new rules in CI. These are ruff's historical defaults.
47
+ 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.4"
4
+ __version__ = "0.6.1"
5
5
 
6
6
  import click
7
7
  import typer
@@ -132,6 +132,26 @@ def _push_container(image_name: str) -> None:
132
132
  shell_run(f"docker push {image_name}")
133
133
 
134
134
 
135
+ def _get_image_digest(image_name: str) -> str | None:
136
+ """Best-effort repo digest (sha256:…) of a locally-tagged image after push."""
137
+ try:
138
+ result = subprocess.run(
139
+ ["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image_name],
140
+ capture_output=True,
141
+ text=True,
142
+ check=False,
143
+ )
144
+ except OSError:
145
+ return None
146
+ if result.returncode != 0:
147
+ return None
148
+ # RepoDigests entries look like "ghcr.io/org/app@sha256:…".
149
+ repo_digest = result.stdout.strip()
150
+ if "@" in repo_digest:
151
+ return repo_digest.split("@", 1)[1]
152
+ return None
153
+
154
+
135
155
  def _require_publish_value(
136
156
  name: str, value, *, allow_empty_list: bool = False, allow_none: bool = False
137
157
  ):
@@ -157,34 +177,36 @@ def _build_application_payload(
157
177
  payload = app_config.to_request_payload(
158
178
  include_deployment_data=include_deployment_data,
159
179
  is_staging=staging,
160
- method="POST",
161
180
  )
162
181
 
163
- _require_publish_value("name", payload["name"])
164
- _require_publish_value("display_name", payload["display_name"])
165
- _require_publish_value("description", payload["description"])
166
- _require_publish_value("type", payload["type"])
167
- _require_publish_value("visibility", payload["visibility"])
168
- _require_publish_value(
169
- "organisation_id", payload["organisation_id"], allow_none=True
170
- )
171
- _require_publish_value(
172
- "container_registry_profile_id",
173
- payload["container_registry_profile_id"],
174
- allow_none=True if payload["type"] != "DEV" else False,
175
- )
176
- _require_publish_value("depends_on", payload["depends_on"], allow_empty_list=True)
182
+ # `name` is the only field the CLI insists on -- it is what the control plane
183
+ # upserts on, so without it there is nothing to publish to.
184
+ if not payload.get("name"):
185
+ raise typer.BadParameter("name is required in doover_config.json.")
186
+
187
+ # Everything else is validated only if the file declares it: an omitted field
188
+ # is not an error, it just isn't part of this patch, and the control plane
189
+ # still rejects a *create* that is missing something it requires. A FIX-ME is
190
+ # always caught, since that is never a deliberate value.
191
+ for field, kwargs in (
192
+ ("name", {}),
193
+ ("display_name", {}),
194
+ ("description", {}),
195
+ ("type", {}),
196
+ ("visibility", {}),
197
+ ("organisation_id", {"allow_none": True}),
198
+ ("container_registry_profile_id", {"allow_none": True}),
199
+ ("depends_on", {"allow_empty_list": True}),
200
+ ):
201
+ if field in payload:
202
+ _require_publish_value(field, payload[field], **kwargs)
177
203
 
178
204
  if include_deployment_data is False:
179
205
  payload.pop("deployment_data", None)
180
206
 
181
- return {
182
- key: value
183
- for key, value in payload.items()
184
- if value is not None
185
- or key == "organisation_id"
186
- or key == "container_registry_profile_id"
187
- }
207
+ # No None-stripping here: a key only reaches this point because the config
208
+ # declared it, and declaring `"organisation_id": null` means clear it.
209
+ return payload
188
210
 
189
211
 
190
212
  def _should_export_ui(app_config) -> bool:
@@ -202,18 +224,15 @@ def _get_persisted_application_id(app_config, *, staging: bool) -> int | None:
202
224
  return int(app_id) if app_id is not None else None
203
225
 
204
226
 
205
- def _persist_application_id(app_config, *, staging: bool, application_id: int) -> None:
206
- if staging:
207
- app_config.staging_config["id"] = application_id
208
- else:
209
- app_config.id = application_id
210
-
211
- app_config.save_to_disk()
212
-
227
+ def _resolve_application_id(client, app_config, *, staging: bool) -> int | None:
228
+ """The application's id, from doover_config.json if it is pinned there, else
229
+ looked up by name.
213
230
 
214
- def _resolve_existing_application_id(
215
- client, app_config, *, staging: bool
216
- ) -> int | None:
231
+ Application names are globally unique, so the name identifies the app on its
232
+ own and new projects need no id in their config at all. Older configs still
233
+ pin one; that wins, so renaming `name` there keeps renaming the app instead
234
+ of forking a new one.
235
+ """
217
236
  app_id = _get_persisted_application_id(app_config, staging=staging)
218
237
  if app_id is not None:
219
238
  return app_id
@@ -228,15 +247,13 @@ def _resolve_existing_application_id(
228
247
  item for item in page.results if getattr(item, "name", None) == app_config.name
229
248
  ]
230
249
  if len(matches) > 1:
250
+ # Shouldn't happen — the control plane enforces name uniqueness — but a
251
+ # silent wrong pick here would publish over the wrong app.
231
252
  raise typer.BadParameter(
232
253
  f"Multiple applications found matching name '{app_config.name}'. Set the application id in doover_config.json."
233
254
  )
234
255
  if len(matches) == 1:
235
- application_id = int(matches[0].id)
236
- _persist_application_id(
237
- app_config, staging=staging, application_id=application_id
238
- )
239
- return application_id
256
+ return int(matches[0].id)
240
257
  return None
241
258
 
242
259
 
@@ -764,10 +781,12 @@ def put_widget(
764
781
  client, renderer = get_state()
765
782
 
766
783
  resolved_staging = _resolve_staging(staging)
767
- application_id = _get_persisted_application_id(app_config, staging=resolved_staging)
784
+ application_id = _resolve_application_id(
785
+ client, app_config, staging=resolved_staging
786
+ )
768
787
  if application_id is None:
769
788
  rich.print(
770
- "[red]No application ID found in doover_config.json. Publish the app first.[/red]"
789
+ f"[red]No application named '{app_config.name}' found. Publish the app first.[/red]"
771
790
  )
772
791
  raise typer.Exit(1)
773
792
 
@@ -946,6 +965,36 @@ def publish(
946
965
  "when publishing several apps that share a single build.",
947
966
  ),
948
967
  ] = True,
968
+ release: Annotated[
969
+ bool,
970
+ typer.Option(
971
+ help="Release the application after publishing. Disable with --no-release.",
972
+ ),
973
+ ] = True,
974
+ digest: Annotated[
975
+ str | None,
976
+ typer.Option(
977
+ help="Pushed image digest (e.g. sha256:…) to use for the release. "
978
+ "For container builds this overrides the automatically detected digest."
979
+ ),
980
+ ] = None,
981
+ tag: Annotated[
982
+ str | None,
983
+ typer.Option(help="Optional version label (e.g. semver / image tag)."),
984
+ ] = None,
985
+ commit: Annotated[
986
+ str | None,
987
+ typer.Option(
988
+ help="Source git commit SHA for the release. Defaults to the repo's HEAD."
989
+ ),
990
+ ] = None,
991
+ notes: Annotated[str, typer.Option(help="Optional release notes.")] = "",
992
+ alpha: Annotated[
993
+ bool,
994
+ typer.Option(
995
+ help="Publish the release as a prerelease that is not auto-selected as 'Latest'."
996
+ ),
997
+ ] = False,
949
998
  app_name: Annotated[
950
999
  str | None,
951
1000
  typer.Option(
@@ -1023,29 +1072,24 @@ def publish(
1023
1072
 
1024
1073
  try:
1025
1074
  with renderer.loading("Publishing application..."):
1026
- # application_id = _resolve_existing_application_id(
1027
- # client,
1028
- # app_config,
1029
- # staging=resolved_staging,
1030
- # )
1031
1075
  application_id = _get_persisted_application_id(
1032
1076
  app_config, staging=resolved_staging
1033
1077
  )
1034
- if application_id is None:
1035
- print(json.dumps(payload, indent=4))
1036
- created = client.applications.create(body=dict(payload))
1037
- application_id = int(created.id)
1038
- _persist_application_id(
1039
- app_config,
1040
- staging=resolved_staging,
1041
- application_id=application_id,
1078
+ if application_id is not None:
1079
+ # Config pins an id — update that app directly, so changing
1080
+ # `name` renames it rather than creating a second app.
1081
+ response = client.applications.partial(
1082
+ str(application_id),
1083
+ body=dict(payload),
1042
1084
  )
1043
- print(f"Created new application with id: {application_id}")
1044
-
1045
- response = client.applications.partial(
1046
- str(application_id),
1047
- body=dict(payload),
1048
- )
1085
+ else:
1086
+ # No id: POST and let the control plane upsert on the globally
1087
+ # unique name — creating the app if the name is free, updating it
1088
+ # if we're allowed to. Nothing is written back to
1089
+ # doover_config.json, so the repo stays the source of truth and a
1090
+ # fresh clone publishes identically.
1091
+ response = client.applications.create(body=dict(payload))
1092
+ application_id = int(response.id)
1049
1093
  except typer.Exit:
1050
1094
  raise
1051
1095
  except click.Abort:
@@ -1093,12 +1137,28 @@ def publish(
1093
1137
  application_id,
1094
1138
  root_fp,
1095
1139
  )
1096
- print(
1097
- "Done! Run `doover app release` to publish an immutable version of this app."
1098
- )
1140
+ print("Done!")
1099
1141
  renderer.render(processor_response or response)
1142
+ if release:
1143
+ ctx.invoke(
1144
+ release_command,
1145
+ app_fp=root_fp,
1146
+ digest=digest,
1147
+ tag=tag,
1148
+ commit=commit,
1149
+ notes=notes,
1150
+ alpha=alpha,
1151
+ staging=staging,
1152
+ app_name=app_name,
1153
+ )
1154
+ else:
1155
+ rich.print(
1156
+ "Run [blue]doover app release[/blue] to publish an immutable "
1157
+ "version of this app."
1158
+ )
1100
1159
  raise typer.Exit(0)
1101
1160
 
1161
+ detected_digest: str | None = None
1102
1162
  if build_container:
1103
1163
  image_name = _require_publish_value("image_name", payload.get("image_name"))
1104
1164
  build_args = getattr(app_config, "build_args", "") or ""
@@ -1111,15 +1171,30 @@ def publish(
1111
1171
  image_name=image_name,
1112
1172
  )
1113
1173
  _push_container(image_name)
1174
+ detected_digest = _get_image_digest(image_name)
1114
1175
  else:
1115
1176
  print("App requested to not build. Skipping build step.")
1116
1177
 
1178
+ release_digest = digest or detected_digest
1179
+ if release and release_digest is not None:
1180
+ ctx.invoke(
1181
+ release_command,
1182
+ app_fp=root_fp,
1183
+ digest=release_digest,
1184
+ tag=tag,
1185
+ commit=commit,
1186
+ notes=notes,
1187
+ alpha=alpha,
1188
+ staging=staging,
1189
+ app_name=app_name,
1190
+ )
1191
+
1117
1192
  print("\n\nDone!")
1118
1193
  renderer.render(response)
1119
1194
 
1120
1195
 
1121
1196
  @app.command(name="release")
1122
- def release(
1197
+ def release_command(
1123
1198
  app_fp: Annotated[
1124
1199
  Path, typer.Argument(help="Path to the application directory.")
1125
1200
  ] = Path(),
@@ -1168,7 +1243,13 @@ def release(
1168
1243
  app_config = get_app_config(root_fp, app_name=app_name)
1169
1244
  resolved_staging = _resolve_staging(staging)
1170
1245
 
1171
- application_id = _get_persisted_application_id(app_config, staging=resolved_staging)
1246
+ client, renderer = get_state()
1247
+
1248
+ # `release` runs as its own process (CI invokes it after `publish`), so it
1249
+ # resolves the id itself — by name when the config doesn't pin one.
1250
+ application_id = _resolve_application_id(
1251
+ client, app_config, staging=resolved_staging
1252
+ )
1172
1253
  if application_id is None:
1173
1254
  rich.print(
1174
1255
  "[red]Application has not been published yet. Run `doover app publish` first.[/red]"
@@ -1183,7 +1264,6 @@ def release(
1183
1264
 
1184
1265
  resolved_commit = commit if commit is not None else _detect_git_commit(root_fp)
1185
1266
 
1186
- client, renderer = get_state()
1187
1267
  with renderer.loading("Creating application version..."):
1188
1268
  version = client.create_application_version(
1189
1269
  application_id,
@@ -6,6 +6,7 @@ import contextlib
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ import rich
9
10
  import typer
10
11
  import questionary
11
12
  from pydoover.models.control import Application as ControlApplication
@@ -13,11 +14,62 @@ from pydoover.models.control import Application as ControlApplication
13
14
  from .shell_commands import run
14
15
 
15
16
 
16
- def get_id_or_key(data: dict[str, Any], key: str) -> Any:
17
- try:
18
- return data[f"{key}_id"]
19
- except KeyError:
20
- return data.get(key)
17
+ # Legacy spellings of the two reference fields. Still read so existing configs
18
+ # keep working, but warned about — the canonical name is the target value.
19
+ DEPRECATED_REFS: dict[str, str] = {
20
+ "owner_org_id": "organisation_id",
21
+ "owner_org": "organisation_id",
22
+ "organisation": "organisation_id",
23
+ "container_registry_profile": "container_registry_profile_id",
24
+ }
25
+
26
+ # Fields that do nothing: left behind by an older CLI that wrote them, or
27
+ # hand-written in the belief they did something. Maps to a replacement where one
28
+ # exists, or None where the field should simply be deleted.
29
+ RETIRED_FIELDS: dict[str, str | None] = {
30
+ # never read, in any version
31
+ "owner_org_key": "organisation_id",
32
+ "organisation_key": "organisation_id",
33
+ "container_registry_profile_key": "container_registry_profile_id",
34
+ "code_repo_key": None,
35
+ # retired here: never sent anywhere and never drove any behaviour
36
+ "code_repo_id": None,
37
+ "code_repo": None,
38
+ "repo_branch": None,
39
+ # cloud-owned state a config file has no business asserting. `lambda_arn` in
40
+ # particular used to be published, so a stale committed value could point an
41
+ # app at the wrong function.
42
+ "lambda_arn": None,
43
+ "stars": None,
44
+ }
45
+
46
+ # Never included in a publish payload, whatever the config says.
47
+ NEVER_SENT = frozenset({"lambda_arn", "stars"})
48
+
49
+
50
+ def resolve_ref(data: dict[str, Any], canonical: str) -> Any:
51
+ """Read a reference field by its canonical name, falling back to the legacy
52
+ spellings and telling the user to rename them."""
53
+ if canonical in data:
54
+ return data[canonical]
55
+
56
+ for legacy, target in DEPRECATED_REFS.items():
57
+ if target == canonical and legacy in data:
58
+ rich.print(
59
+ f"[yellow]doover_config.json: '{legacy}' is deprecated -- rename it "
60
+ f"to '{canonical}'.[/yellow]"
61
+ )
62
+ return data[legacy]
63
+ return None
64
+
65
+
66
+ def warn_retired_fields(data: dict[str, Any]) -> None:
67
+ for key in sorted(set(RETIRED_FIELDS) & set(data)):
68
+ replacement = RETIRED_FIELDS[key]
69
+ advice = f"use '{replacement}'" if replacement else "delete it"
70
+ rich.print(
71
+ f"[yellow]doover_config.json: '{key}' has no effect -- {advice}.[/yellow]"
72
+ )
21
73
 
22
74
 
23
75
  @contextlib.contextmanager
@@ -61,18 +113,14 @@ class LocalApplication(ControlApplication):
61
113
  depends_on: list[str] | None = None,
62
114
  organisation: dict[str, Any] | str | int | None = None,
63
115
  approx_installs: int | None = None,
64
- stars: int | None = None,
65
116
  container_registry_profile: dict[str, Any] | str | int | None = None,
66
117
  deployment_data: str | None = None,
67
118
  image_name: str | None = None,
68
- lambda_arn: str | None = None,
69
119
  lambda_config: Any | None = None,
70
120
  config_profiles: list[Any] | None = None,
71
121
  icon_url: str | None = None,
72
122
  banner_url: str | None = None,
73
123
  key: str | None = None,
74
- code_repo_id: str | int | None = None,
75
- repo_branch: str | None = None,
76
124
  build_args: str | None = None,
77
125
  widget: str | Path | None = None,
78
126
  build_widget_command: str | None = None,
@@ -99,11 +147,9 @@ class LocalApplication(ControlApplication):
99
147
  depends_on=depends_on,
100
148
  organisation=organisation,
101
149
  approx_installs=approx_installs,
102
- stars=stars,
103
150
  container_registry_profile=container_registry_profile,
104
151
  deployment_data=deployment_data,
105
152
  image_name=image_name,
106
- lambda_arn=lambda_arn,
107
153
  lambda_config=lambda_config,
108
154
  config_profiles=config_profiles,
109
155
  icon_url=icon_url,
@@ -131,8 +177,6 @@ class LocalApplication(ControlApplication):
131
177
 
132
178
  self.build_widget_command = build_widget_command
133
179
  self.key = key
134
- self.code_repo_id = code_repo_id
135
- self.repo_branch = repo_branch or "main"
136
180
  self.build_args = build_args
137
181
  self.export_config_command = export_config_command
138
182
  self.export_ui_command = export_ui_command
@@ -141,16 +185,41 @@ class LocalApplication(ControlApplication):
141
185
  self.staging_config = staging_config or {}
142
186
  self.base_path = base_path
143
187
  self.deployment_folder = deployment_folder or "deployment"
188
+ # Set by from_config. None means "built in code, not parsed from a file",
189
+ # in which case to_request_payload sends everything as it always did.
190
+ self._provided_keys: set[str] | None = None
191
+ self._null_keys: set[str] = set()
144
192
 
145
193
  @property
146
194
  def src_directory(self) -> Path:
147
195
  return (self.base_path or Path()) / "src" / (self.name or "").replace("-", "_")
148
196
 
149
- @staticmethod
150
- def _request_payload_keys() -> set[str]:
151
- fields = ControlApplication._versions["ApplicationSerializerDetailRequest"][
152
- "fields"
153
- ]
197
+ # Legacy spellings still land on one request field, so provided-key tracking
198
+ # has to normalise them.
199
+ _KEY_ALIASES = DEPRECATED_REFS
200
+
201
+ # The PATCH shape, which marks nothing as required. Publishing sends only what
202
+ # doover_config.json declares, so `name` -- the key the control plane upserts
203
+ # on -- is the one field the CLI insists on. Everything else is the server's
204
+ # problem: it still rejects a *create* that is missing something required,
205
+ # while an update simply leaves the undeclared fields alone.
206
+ _REQUEST_VERSION = "PatchedApplicationSerializerDetailRequest"
207
+
208
+ # Never filtered out: `name` identifies the app, and deployment_data is built
209
+ # from the deployment/ folder rather than read from the JSON.
210
+ _ALWAYS_SENT = frozenset({"name", "deployment_data"})
211
+
212
+ def _normalise_key(self, key: str) -> str:
213
+ return self._KEY_ALIASES.get(key, key)
214
+
215
+ def _provided_payload_keys(self) -> set[str] | None:
216
+ if self._provided_keys is None:
217
+ return None
218
+ return {self._normalise_key(key) for key in self._provided_keys}
219
+
220
+ @classmethod
221
+ def _request_payload_keys(cls) -> set[str]:
222
+ fields = ControlApplication._versions[cls._REQUEST_VERSION]["fields"]
154
223
  keys = set()
155
224
  for field_name, config in fields.items():
156
225
  keys.add(config.get("output_id", field_name))
@@ -167,7 +236,8 @@ class LocalApplication(ControlApplication):
167
236
 
168
237
  @classmethod
169
238
  def from_config(cls, data: dict[str, Any], app_base: Path) -> "LocalApplication":
170
- return cls(
239
+ warn_retired_fields(data)
240
+ instance = cls(
171
241
  id=data.get("id"),
172
242
  key=data.get("key"),
173
243
  name=data.get("name"),
@@ -178,17 +248,12 @@ class LocalApplication(ControlApplication):
178
248
  description=data.get("description"),
179
249
  long_description=data.get("long_description"),
180
250
  depends_on=list(data.get("depends_on", [])),
181
- organisation=get_id_or_key(data, "owner_org")
182
- or get_id_or_key(data, "organisation"),
183
- code_repo_id=get_id_or_key(data, "code_repo"),
184
- repo_branch=data.get("repo_branch"),
251
+ organisation=resolve_ref(data, "organisation_id"),
185
252
  image_name=data.get("image_name"),
186
253
  build_args=data.get("build_args"),
187
- container_registry_profile=get_id_or_key(
188
- data,
189
- "container_registry_profile",
254
+ container_registry_profile=resolve_ref(
255
+ data, "container_registry_profile_id"
190
256
  ),
191
- lambda_arn=data.get("lambda_arn"),
192
257
  lambda_config=data.get("lambda_config"),
193
258
  widget=data.get("widget"),
194
259
  build_widget_command=data.get("build_widget_command"),
@@ -204,15 +269,37 @@ class LocalApplication(ControlApplication):
204
269
  base_path=app_base,
205
270
  deployment_folder=data.get("deployment_folder"),
206
271
  )
272
+ # Which keys doover_config.json actually spelled out. Every attribute
273
+ # above defaults when absent, so without this the object can't tell
274
+ # "omitted" from "set to the default" — and publishing would push those
275
+ # defaults over whatever the cloud holds. See to_request_payload.
276
+ staging = data.get("staging_config") or {}
277
+ instance._provided_keys = set(data) | set(staging)
278
+ # Declared as an explicit null. `to_version` drops None-valued attributes,
279
+ # so these have to be put back by hand -- omitting a field and setting it
280
+ # to null are different instructions, and only the second one should
281
+ # clear the value in the cloud.
282
+ instance._null_keys = {
283
+ instance._normalise_key(key)
284
+ for source in (data, staging)
285
+ for key, value in source.items()
286
+ if value is None
287
+ }
288
+ return instance
207
289
 
208
290
  def to_request_payload(
209
291
  self,
210
292
  *,
211
293
  include_deployment_data: bool = False,
212
294
  is_staging: bool = False,
213
- method: str = "POST",
295
+ method: str | None = None,
296
+ only_provided: bool = True,
214
297
  ) -> dict[str, Any]:
215
- payload = self.to_version("ApplicationSerializerDetailRequest", method=method)
298
+ # `method` is accepted for callers that still pass it, but the shape is
299
+ # always the PATCH one -- see _REQUEST_VERSION. The verb is decided by the
300
+ # publish flow (POST to upsert, PATCH when an id is pinned), not here.
301
+ _ = method
302
+ payload = self.to_version(self._REQUEST_VERSION)
216
303
  if is_staging:
217
304
  payload.update(
218
305
  {
@@ -225,26 +312,43 @@ class LocalApplication(ControlApplication):
225
312
  if include_deployment_data:
226
313
  payload["deployment_data"] = self._deployment_data()
227
314
 
228
- return payload
315
+ # doover_config.json is a patch, not a full replacement: a field it
316
+ # spells out is the source of truth, a field it omits keeps whatever the
317
+ # cloud already has. Without this, every attribute's fallback (False,
318
+ # None, []) would be published as if it were a deliberate choice —
319
+ # notably organisation_id, which would be nulled out on every publish.
320
+ provided = self._provided_payload_keys() if only_provided else None
321
+ if provided is not None:
322
+ payload = {
323
+ key: value
324
+ for key, value in payload.items()
325
+ if key in provided or key in self._ALWAYS_SENT
326
+ }
327
+ # `null` in the file is an instruction to clear the field, so restore
328
+ # the entries to_version dropped for being None.
329
+ for key in self._null_keys & self._request_payload_keys():
330
+ payload[key] = None
331
+
332
+ return {key: value for key, value in payload.items() if key not in NEVER_SENT}
229
333
 
230
334
  def to_config_dict(
231
335
  self,
232
336
  include_deployment_data: bool = False,
233
337
  is_staging: bool = False,
234
- include_cloud_only: bool = False,
235
338
  ) -> dict[str, Any]:
236
339
  data = self.to_request_payload(
237
340
  include_deployment_data=include_deployment_data,
238
341
  is_staging=is_staging,
239
342
  method="POST",
343
+ # A config dump describes the app in full, so it is not a patch.
344
+ only_provided=False,
240
345
  )
241
346
  data.update(
242
347
  {
243
348
  "id": self.id,
244
349
  "key": self.key,
245
- "owner_org_id": data.get("organisation_id"),
246
- "code_repo_id": self.code_repo_id,
247
- "repo_branch": self.repo_branch,
350
+ # Deliberately no `owner_org_id` twin here — writing both is what
351
+ # left most repos carrying two spellings of the same field.
248
352
  "build_args": self.build_args,
249
353
  "image_name": self.image_name,
250
354
  "lambda_config": self.lambda_config,
@@ -269,41 +373,18 @@ class LocalApplication(ControlApplication):
269
373
  data["generate_ui"] = self.generate_ui
270
374
  data["run_command"] = self.run_command
271
375
 
272
- if include_cloud_only:
273
- data["lambda_arn"] = self.lambda_arn
274
-
275
376
  return data
276
377
 
277
378
  def to_dict(
278
379
  self,
279
380
  include_deployment_data: bool = False,
280
381
  is_staging: bool = False,
281
- include_cloud_only: bool = False,
282
382
  ) -> dict[str, Any]:
283
383
  return self.to_config_dict(
284
384
  include_deployment_data=include_deployment_data,
285
385
  is_staging=is_staging,
286
- include_cloud_only=include_cloud_only,
287
386
  )
288
387
 
289
- def save_to_disk(self) -> None:
290
- if self.base_path is None:
291
- raise ValueError("Application base path is not set.")
292
-
293
- config_path = self.base_path / "doover_config.json"
294
- app_name = self.name or ""
295
- data: dict[str, dict[str, Any]] = (
296
- dict(json.loads(config_path.read_text()))
297
- if config_path.exists()
298
- else {app_name: {}}
299
- )
300
-
301
- upstream = self.to_config_dict(include_cloud_only=True)
302
- upstream.pop("long_description", None)
303
-
304
- data.setdefault(app_name, {}).update(**upstream)
305
- config_path.write_text(json.dumps(data, indent=4))
306
-
307
388
 
308
389
  def get_app_directory(root: Path | None = None) -> Path:
309
390
  root_fp = root or Path()