caffeinated-whale-cli 2.3.0__tar.gz → 2.3.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 (90) hide show
  1. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/PKG-INFO +8 -3
  2. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/README.md +7 -2
  3. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/pyproject.toml +1 -1
  4. caffeinated_whale_cli-2.3.1/src/caffeinated_whale_cli/__init__.py +1 -0
  5. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/axi.py +1 -1
  6. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/init.py +13 -4
  7. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/start.py +12 -2
  8. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/init.py +67 -7
  9. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/scale.py +12 -9
  10. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/start.py +43 -2
  11. caffeinated_whale_cli-2.3.0/src/caffeinated_whale_cli/__init__.py +0 -1
  12. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/LICENSE +0 -0
  13. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/setup.cfg +0 -0
  14. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/__init__.py +0 -0
  15. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/apps.py +0 -0
  16. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/backup.py +0 -0
  17. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/config.py +0 -0
  18. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/console.html +0 -0
  19. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/doctor.py +0 -0
  20. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/inspect.py +0 -0
  21. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/label.py +0 -0
  22. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/list.py +0 -0
  23. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/logs.py +0 -0
  24. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/open.py +0 -0
  25. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/restart.py +0 -0
  26. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/restore.py +0 -0
  27. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/rm.py +0 -0
  28. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/rm_site.py +0 -0
  29. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/run.py +0 -0
  30. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/scale.py +0 -0
  31. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/self_update.py +0 -0
  32. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/serve.py +0 -0
  33. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/status.py +0 -0
  34. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/stop.py +0 -0
  35. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/unlock.py +0 -0
  36. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/update.py +0 -0
  37. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/utils.py +0 -0
  38. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/where.py +0 -0
  39. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/__init__.py +0 -0
  40. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/apps.py +0 -0
  41. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/auto_inspect.py +0 -0
  42. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/backup.py +0 -0
  43. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/bench_ops.py +0 -0
  44. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/config.py +0 -0
  45. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/credbridge.py +0 -0
  46. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/docker.py +0 -0
  47. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/doctor.py +0 -0
  48. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/envelope.py +0 -0
  49. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/errors.py +0 -0
  50. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/exec_stream.py +0 -0
  51. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/fleet.py +0 -0
  52. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/inspect.py +0 -0
  53. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/label.py +0 -0
  54. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/list.py +0 -0
  55. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/logs.py +0 -0
  56. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/open.py +0 -0
  57. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/resolvers.py +0 -0
  58. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/restart.py +0 -0
  59. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/restore.py +0 -0
  60. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/rm.py +0 -0
  61. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/rm_site.py +0 -0
  62. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/run.py +0 -0
  63. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/status.py +0 -0
  64. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/stop.py +0 -0
  65. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/supervision.py +0 -0
  66. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/unlock.py +0 -0
  67. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/update.py +0 -0
  68. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/url.py +0 -0
  69. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/version.py +0 -0
  70. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/where.py +0 -0
  71. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/main.py +0 -0
  72. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/update_notice.py +0 -0
  73. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/__init__.py +0 -0
  74. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/agent_hooks.py +0 -0
  75. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/auto_inspect.py +0 -0
  76. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/bench_labels.py +0 -0
  77. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/bench_sites.py +0 -0
  78. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/cache.py +0 -0
  79. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/completion_utils.py +0 -0
  80. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/config_utils.py +0 -0
  81. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/console.py +0 -0
  82. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/db_utils.py +0 -0
  83. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/docker_utils.py +0 -0
  84. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/port_utils.py +0 -0
  85. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/sendme_utils.py +0 -0
  86. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/startup.py +0 -0
  87. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/tips.py +0 -0
  88. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/toon.py +0 -0
  89. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/vscode_utils.py +0 -0
  90. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli.egg-info/SOURCES.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caffeinated-whale-cli
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: A CLI tool to help manage Frappe Docker instances.
5
5
  Author-email: Christopher McKay <mckay.christopher73@outlook.com>
6
6
  License: MIT
@@ -320,15 +320,19 @@ Then open http://development.localhost:8000 (or `cwcli open my-project`).
320
320
  If the dev services fail to start, init still exits successfully (the bench was already created) and prints a warning telling you to run `cwcli start` yourself.
321
321
  The start itself waits for the web server to answer on the bench's own configured port before declaring success; if that times out, init reports the same "not running" output even though the containers and supervisor did launch, since the web isn't actually serving yet.
322
322
  If the port cannot be read, start skips the wait and warns instead of probing another bench's port.
323
+ If the web server is running inside the container but its port is not published to the host, init prints a warning naming the missing host binding and advises recreating the instance.
323
324
 
324
325
  **Port Conflict Handling:**
325
326
 
326
- If the requested ports are in use, you'll see:
327
+ Init retries occupied ports five times, one second apart, to allow bindings from a just-removed instance to finish releasing.
328
+ Each retry is announced in both interactive and non-interactive runs.
329
+ If the ports remain occupied, init exits nonzero before creating the instance and names every conflicting port:
327
330
 
328
331
  ```
329
332
  Error: The following ports are already in use: 8000-8005
330
333
 
331
- Tip: Use the --port flag to select a different starting port.
334
+ Tip: If an instance using these ports was just removed, they may still be releasing - wait a few seconds and retry.
335
+ Tip: Otherwise, use the --port flag to select a different starting port.
332
336
  Example: cwcli init my-project --port 10000
333
337
  ```
334
338
 
@@ -498,6 +502,7 @@ cwcli start [OPTIONS] [PROJECT_NAME]...
498
502
  - **Idempotent:** a re-run on an already-running bench is a clean no-op ("already running: N/N processes up"), never a second supervisor stack
499
503
  - **Per-process supervision:** each Procfile process runs under supervisord, so one can be restarted or auto-healed without disturbing the others
500
504
  - **Waits for the web server:** on a genuine launch, `start` blocks until the bench's **own** web port actually answers before reporting the bench as running, so a scripted `cwcli start && cwcli status` never catches a transient `degraded`. The port is read from that bench's `sites/common_site_config.json`, never assumed - waiting on a hardcoded `:8000` meant `--bench 1` watched bench 0's port and then warned that a healthy bench had not started. If the port cannot be read, the wait is **skipped** (`web_ready` stays unset) rather than spent on a guess. A timeout (60s) does not fail the start (the stack IS launched) - it prints a warning naming the real port and telling you to check `cwcli status` / `cwcli logs`
505
+ - **Host port verification:** on both a genuine launch and an idempotent no-op, `start` warns when the bench's web server has no live host port binding. The warning names the container port and advises recreating the instance with `cwcli init`
501
506
  - **Port Conflict Detection:** Automatically checks if required ports are available
502
507
  - **Interactive Resolution:** Offers to stop conflicting Frappe projects (use `--yes` to auto-confirm)
503
508
  - **Process Identification:** Shows which processes are using ports (cross-platform)
@@ -280,15 +280,19 @@ Then open http://development.localhost:8000 (or `cwcli open my-project`).
280
280
  If the dev services fail to start, init still exits successfully (the bench was already created) and prints a warning telling you to run `cwcli start` yourself.
281
281
  The start itself waits for the web server to answer on the bench's own configured port before declaring success; if that times out, init reports the same "not running" output even though the containers and supervisor did launch, since the web isn't actually serving yet.
282
282
  If the port cannot be read, start skips the wait and warns instead of probing another bench's port.
283
+ If the web server is running inside the container but its port is not published to the host, init prints a warning naming the missing host binding and advises recreating the instance.
283
284
 
284
285
  **Port Conflict Handling:**
285
286
 
286
- If the requested ports are in use, you'll see:
287
+ Init retries occupied ports five times, one second apart, to allow bindings from a just-removed instance to finish releasing.
288
+ Each retry is announced in both interactive and non-interactive runs.
289
+ If the ports remain occupied, init exits nonzero before creating the instance and names every conflicting port:
287
290
 
288
291
  ```
289
292
  Error: The following ports are already in use: 8000-8005
290
293
 
291
- Tip: Use the --port flag to select a different starting port.
294
+ Tip: If an instance using these ports was just removed, they may still be releasing - wait a few seconds and retry.
295
+ Tip: Otherwise, use the --port flag to select a different starting port.
292
296
  Example: cwcli init my-project --port 10000
293
297
  ```
294
298
 
@@ -458,6 +462,7 @@ cwcli start [OPTIONS] [PROJECT_NAME]...
458
462
  - **Idempotent:** a re-run on an already-running bench is a clean no-op ("already running: N/N processes up"), never a second supervisor stack
459
463
  - **Per-process supervision:** each Procfile process runs under supervisord, so one can be restarted or auto-healed without disturbing the others
460
464
  - **Waits for the web server:** on a genuine launch, `start` blocks until the bench's **own** web port actually answers before reporting the bench as running, so a scripted `cwcli start && cwcli status` never catches a transient `degraded`. The port is read from that bench's `sites/common_site_config.json`, never assumed - waiting on a hardcoded `:8000` meant `--bench 1` watched bench 0's port and then warned that a healthy bench had not started. If the port cannot be read, the wait is **skipped** (`web_ready` stays unset) rather than spent on a guess. A timeout (60s) does not fail the start (the stack IS launched) - it prints a warning naming the real port and telling you to check `cwcli status` / `cwcli logs`
465
+ - **Host port verification:** on both a genuine launch and an idempotent no-op, `start` warns when the bench's web server has no live host port binding. The warning names the container port and advises recreating the instance with `cwcli init`
461
466
  - **Port Conflict Detection:** Automatically checks if required ports are available
462
467
  - **Interactive Resolution:** Offers to stop conflicting Frappe projects (use `--yes` to auto-confirm)
463
468
  - **Process Identification:** Shows which processes are using ports (cross-platform)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "caffeinated-whale-cli"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  authors = [
9
9
  { name = "Christopher McKay", email = "mckay.christopher73@outlook.com" },
10
10
  ]
@@ -0,0 +1 @@
1
+ __version__ = "2.3.1"
@@ -2336,7 +2336,7 @@ def axi_init(
2336
2336
  )
2337
2337
  else:
2338
2338
  for warning in start_result.warnings:
2339
- if warning.code == "start.web_not_ready":
2339
+ if warning.code in ("start.web_not_ready", "start.no_host_port"):
2340
2340
  print(f"Warning: {warning.text}", file=sys.stderr, flush=True)
2341
2341
 
2342
2342
  emit_result(bench_result.data, warnings=bench_result.warnings)
@@ -173,6 +173,10 @@ class _InitRenderer:
173
173
  stderr_console.print(f"[yellow]Warning: {event.text}[/yellow]")
174
174
  elif code == "instance.already_running" and self.verbose:
175
175
  stderr_console.print(f"[dim]{event.text}[/dim]")
176
+ elif code == "ports.retry":
177
+ # Unmissable in both rendering modes: a caller waiting on this run
178
+ # needs to know it's retrying, not stuck.
179
+ stderr_console.print(f"[yellow]{event.text}[/yellow]")
176
180
 
177
181
  def _on_trace(self, event) -> None:
178
182
  if not self.verbose:
@@ -191,7 +195,11 @@ def _render_error_exit(e: CwcliError, project_name: str, *, verbose: bool = Fals
191
195
  stderr_console.print(f"[bold red]Error:[/bold red] {e.message}")
192
196
  if e.code == "ports.in_use":
193
197
  stderr_console.print(
194
- "\n[yellow]Tip:[/yellow] Use the [cyan]--port[/cyan] flag to select "
198
+ "\n[yellow]Tip:[/yellow] If an instance using these ports was just removed, "
199
+ "they may still be releasing - wait a few seconds and retry."
200
+ )
201
+ stderr_console.print(
202
+ "[yellow]Tip:[/yellow] Otherwise, use the [cyan]--port[/cyan] flag to select "
195
203
  "a different starting port."
196
204
  )
197
205
  stderr_console.print(f"[dim]Example: cwcli init {project_name} --port 10000[/dim]")
@@ -376,10 +384,11 @@ def _start_services(project: str, bench_path: str) -> tuple[bool, bool | None]:
376
384
  )
377
385
  return False, None
378
386
  # core.start now blocks until the web server binds this bench's assigned port, so "running" is
379
- # honest by the time we return. If it timed out, surface the warning so the
380
- # user isn't told the web is up when it hasn't begun serving yet.
387
+ # honest by the time we return. If it timed out, or the port never got published
388
+ # to the host at all, surface the warning so the user isn't told the web is up
389
+ # when it isn't reachable.
381
390
  for warning in result.warnings:
382
- if warning.code == "start.web_not_ready":
391
+ if warning.code in ("start.web_not_ready", "start.no_host_port"):
383
392
  stderr_console.print(f"[yellow]Warning:[/yellow] {warning.text}")
384
393
  running = result.status is not Status.NEEDS_CHOICE
385
394
  web_ready = result.data.web_ready if running and result.data is not None else None
@@ -322,7 +322,12 @@ def _start_project(
322
322
  # be visible here too, since restart's whole-stack path and the auto-start
323
323
  # path (ensure_containers_running) both funnel through this helper.
324
324
  for warning in result.warnings:
325
- if warning.code in ("start.uid_align_failed", "bench.default_used", "start.web_not_ready"):
325
+ if warning.code in (
326
+ "start.uid_align_failed",
327
+ "bench.default_used",
328
+ "start.web_not_ready",
329
+ "start.no_host_port",
330
+ ):
326
331
  stderr_console.print(f"[yellow]Warning: {warning.text}[/yellow]")
327
332
  elif verbose:
328
333
  stderr_console.print(f"[dim]{warning.text}[/dim]")
@@ -522,7 +527,12 @@ def _run_start(
522
527
  # init.uid_align_failed precedent for the uid remap failure): both signal the
523
528
  # bench workspace may not behave as expected, not just verbose diagnostics.
524
529
  for warning in result.warnings:
525
- if warning.code in ("start.uid_align_failed", "bench.default_used", "start.web_not_ready"):
530
+ if warning.code in (
531
+ "start.uid_align_failed",
532
+ "bench.default_used",
533
+ "start.web_not_ready",
534
+ "start.no_host_port",
535
+ ):
526
536
  stderr_console.print(f"[yellow]Warning: {warning.text}[/yellow]")
527
537
  elif verbose:
528
538
  stderr_console.print(f"[dim]{warning.text}[/dim]")
@@ -472,6 +472,43 @@ def _project_containers_running(project_name: str) -> bool:
472
472
 
473
473
  _EXPECTED_COMPOSE_SERVICES = frozenset({"frappe", "mariadb", "redis-cache", "redis-queue"})
474
474
 
475
+ # A just-removed instance's own port bindings can take a moment to fully
476
+ # release (Docker's docker-proxy teardown lag, observed in the field to clear
477
+ # within seconds), so a rapid remove-then-reinit (a CI purge-and-recreate
478
+ # pattern) can see those SAME ports as still "in use" right after the old
479
+ # instance is gone. 5 attempts a second apart absorbs that ordinary teardown
480
+ # window before concluding the conflict is real and persistent.
481
+ _PORT_CHECK_ATTEMPTS = 5
482
+ _PORT_CHECK_RETRY_DELAY = 1.0
483
+
484
+
485
+ def _ports_still_in_use(ports: list[int], *, emit: OnEvent) -> list[int]:
486
+ """Check host ports, retrying briefly before giving up.
487
+
488
+ Never silently treats a conflict as "no conflict" - it only delays the
489
+ verdict to absorb a transient teardown window. A port still in use after
490
+ every attempt is reported exactly as before: loudly, deterministically,
491
+ regardless of interactivity.
492
+ """
493
+ ports_in_use: list[int] = []
494
+ for attempt in range(_PORT_CHECK_ATTEMPTS):
495
+ port_status = check_ports_in_use(ports)
496
+ ports_in_use = [p for p, in_use in port_status.items() if in_use]
497
+ if not ports_in_use:
498
+ return []
499
+ if attempt < _PORT_CHECK_ATTEMPTS - 1:
500
+ emit(
501
+ InitNotice(
502
+ code="ports.retry",
503
+ text=(
504
+ f"Port(s) {format_port_list(ports_in_use)} still in use "
505
+ "(may be a just-removed instance releasing them); retrying..."
506
+ ),
507
+ )
508
+ )
509
+ time.sleep(_PORT_CHECK_RETRY_DELAY)
510
+ return ports_in_use
511
+
475
512
 
476
513
  def _running_compose_services(project_name: str) -> set[str]:
477
514
  """Return this project's running compose service names."""
@@ -532,14 +569,17 @@ def init_instance(
532
569
  if not auto_start and not _project_containers_running(project_name):
533
570
  web_ports = list(range(port, port + 6))
534
571
  socketio_ports = list(range(port + 1000, port + 1006))
535
- port_status = check_ports_in_use(web_ports + socketio_ports)
536
- ports_in_use = [p for p, in_use in port_status.items() if in_use]
572
+ ports_in_use = _ports_still_in_use(web_ports + socketio_ports, emit=emit)
537
573
  if ports_in_use:
538
574
  raise CwcliError(
539
575
  ErrorKind.CONFLICT,
540
576
  "ports.in_use",
541
577
  f"The following ports are already in use: {format_port_list(ports_in_use)}",
542
- hint="Use the --port flag to select a different starting port.",
578
+ hint=(
579
+ "If an instance using these ports was just removed, they may still be "
580
+ "releasing - wait a few seconds and retry. Otherwise, use --port to "
581
+ "select a different starting port."
582
+ ),
543
583
  )
544
584
 
545
585
  emit(InitStepStart(phase="project_dir", message="Creating project directory"))
@@ -581,11 +621,11 @@ def init_instance(
581
621
  )
582
622
  )
583
623
  content = compose_path.read_text()
584
- content = content.replace("8000-8005:8000-8005", f"{port}-{port+5}:8000-8005")
624
+ web_mapping = f"{port}-{port + 5}:8000-8005"
625
+ content = content.replace("8000-8005:8000-8005", web_mapping)
585
626
  socketio_start = port + 1000
586
- content = content.replace(
587
- "9000-9005:9000-9005", f"{socketio_start}-{socketio_start+5}:9000-9005"
588
- )
627
+ socketio_mapping = f"{socketio_start}-{socketio_start + 5}:9000-9005"
628
+ content = content.replace("9000-9005:9000-9005", socketio_mapping)
589
629
  # The upstream devcontainer template sets `working_dir: /workspace/development`
590
630
  # - a path cwcli never creates (its bench lives at /workspace/frappe-bench).
591
631
  # /workspace is a bind mount to CWCLI_HOME/projects/<name>/, so on `compose up`
@@ -617,6 +657,26 @@ def init_instance(
617
657
  (project_dir / "data").mkdir(parents=True, exist_ok=True)
618
658
  content = content.replace("- ..:/workspace:cached", f"- ../data:{bench_parent_path}:cached")
619
659
  content = content.replace("working_dir: /workspace", f"working_dir: {bench_parent_path}")
660
+ # frappe_docker removed the devcontainer template's ports block in favor
661
+ # of editor-managed forwarding. cwcli runs the compose project directly,
662
+ # so it must add the host publications when the downloaded template omits
663
+ # both mappings. Without them Docker starts a healthy but host-unreachable
664
+ # instance and every later string replacement remains a silent no-op.
665
+ if web_mapping not in content and socketio_mapping not in content:
666
+ working_dir = f" working_dir: {bench_parent_path}"
667
+ published_ports = (
668
+ f'{working_dir}\n ports:\n - "{web_mapping}"\n - "{socketio_mapping}"'
669
+ )
670
+ content = content.replace(working_dir, published_ports, 1)
671
+ if web_mapping not in content or socketio_mapping not in content:
672
+ compose_path.unlink()
673
+ raise CwcliError(
674
+ ErrorKind.PRECONDITION,
675
+ "compose.ports_missing",
676
+ "The downloaded Docker Compose template could not be configured "
677
+ "with the required host port mappings.",
678
+ hint="Retry init. If the error persists, report the upstream compose change.",
679
+ )
620
680
  compose_path.write_text(content)
621
681
 
622
682
  compose_base = ["docker", "compose", "-p", project_name, "-f", "docker-compose.yml"]
@@ -73,16 +73,17 @@ _WEB_CONTAINER_BASE = 8000
73
73
  _SOCKETIO_CONTAINER_BASE = 9000
74
74
 
75
75
  # The published ``ports:`` lines cwcli writes at init look like
76
- # ``- 16000-16005:8000-8005`` (web) and ``- 17000-17005:9000-9005`` (socketio).
76
+ # ``- 16000-16005:8000-8005`` (web) and ``- 17000-17005:9000-9005`` (socketio),
77
+ # optionally quoted as YAML strings.
77
78
  # Capture the host low bound and the container high bound so we know the current
78
79
  # published count and the host base to preserve when widening.
79
80
  _WEB_PORTS_RE = re.compile(
80
- rf"(?m)^(?P<prefix>\s*-\s*)(?P<host_lo>\d+)-(?P<host_hi>\d+):"
81
- rf"{_WEB_CONTAINER_BASE}-(?P<cont_hi>\d+)\s*$"
81
+ rf"(?m)^(?P<prefix>\s*-\s*)(?P<quote>['\"]?)(?P<host_lo>\d+)-(?P<host_hi>\d+):"
82
+ rf"{_WEB_CONTAINER_BASE}-(?P<cont_hi>\d+)(?P=quote)\s*$"
82
83
  )
83
84
  _SOCKETIO_PORTS_RE = re.compile(
84
- rf"(?m)^(?P<prefix>\s*-\s*)(?P<host_lo>\d+)-(?P<host_hi>\d+):"
85
- rf"{_SOCKETIO_CONTAINER_BASE}-(?P<cont_hi>\d+)\s*$"
85
+ rf"(?m)^(?P<prefix>\s*-\s*)(?P<quote>['\"]?)(?P<host_lo>\d+)-(?P<host_hi>\d+):"
86
+ rf"{_SOCKETIO_CONTAINER_BASE}-(?P<cont_hi>\d+)(?P=quote)\s*$"
86
87
  )
87
88
 
88
89
 
@@ -218,15 +219,17 @@ def _widen_ports_block(compose_text: str, published: _PublishedRange, count: int
218
219
  sio_hi_cont = _SOCKETIO_CONTAINER_BASE + count - 1
219
220
 
220
221
  def web_repl(match: re.Match) -> str:
222
+ quote = match.group("quote")
221
223
  return (
222
- f"{match.group('prefix')}{published.web_base}-{web_hi_host}:"
223
- f"{_WEB_CONTAINER_BASE}-{web_hi_cont}"
224
+ f"{match.group('prefix')}{quote}{published.web_base}-{web_hi_host}:"
225
+ f"{_WEB_CONTAINER_BASE}-{web_hi_cont}{quote}"
224
226
  )
225
227
 
226
228
  def sio_repl(match: re.Match) -> str:
229
+ quote = match.group("quote")
227
230
  return (
228
- f"{match.group('prefix')}{published.socketio_base}-{sio_hi_host}:"
229
- f"{_SOCKETIO_CONTAINER_BASE}-{sio_hi_cont}"
231
+ f"{match.group('prefix')}{quote}{published.socketio_base}-{sio_hi_host}:"
232
+ f"{_SOCKETIO_CONTAINER_BASE}-{sio_hi_cont}{quote}"
230
233
  )
231
234
 
232
235
  compose_text = _WEB_PORTS_RE.sub(web_repl, compose_text, count=1)
@@ -107,6 +107,7 @@ def start(
107
107
  for container in containers:
108
108
  if container.status != "running":
109
109
  container.start()
110
+ container.reload()
110
111
 
111
112
  frappe_container = next(
112
113
  (c for c in containers if c.labels.get("com.docker.compose.service") == "frappe"),
@@ -159,6 +160,18 @@ def start(
159
160
  snapshot = supervision.discover_stack(frappe_container, resolved_path)
160
161
  if snapshot.supervisor_up:
161
162
  if not restart:
163
+ idle_processes = _launched_processes(frappe_container, resolved_path, snapshot)
164
+ idle_warnings = list(warnings)
165
+ if "web" in {p.label for p in idle_processes}:
166
+ idle_ports = resolvers.resolve_assigned_ports(
167
+ frappe_container, [resolved_path], fill_defaults=False
168
+ ).get(resolved_path)
169
+ if idle_ports is not None:
170
+ port_warning = _no_host_port_warning(
171
+ frappe_container, project_name, resolved_path, idle_ports
172
+ )
173
+ if port_warning is not None:
174
+ idle_warnings.append(port_warning)
162
175
  return Result(
163
176
  status=Status.OK,
164
177
  data=StartOutcome(
@@ -168,9 +181,9 @@ def start(
168
181
  supervisor=supervision.SUPERVISOR,
169
182
  log_path=log_path,
170
183
  already_running=True,
171
- processes=_launched_processes(frappe_container, resolved_path, snapshot),
184
+ processes=idle_processes,
172
185
  ),
173
- warnings=warnings,
186
+ warnings=idle_warnings,
174
187
  )
175
188
  supervision.stop_supervisor(frappe_container, resolved_path)
176
189
 
@@ -218,6 +231,11 @@ def start(
218
231
  f"on :{ports[0]} in time. Check 'cwcli status' / 'cwcli logs'.",
219
232
  )
220
233
  )
234
+ port_warning = _no_host_port_warning(
235
+ frappe_container, project_name, resolved_path, ports
236
+ )
237
+ if port_warning is not None:
238
+ warnings.append(port_warning)
221
239
 
222
240
  return Result(
223
241
  status=Status.OK,
@@ -235,6 +253,29 @@ def start(
235
253
  )
236
254
 
237
255
 
256
+ def _no_host_port_warning(
257
+ frappe_container, project_name: str, resolved_path: str, ports: tuple[int, int]
258
+ ) -> Message | None:
259
+ """An unmissable warning when this bench's web server has no live HOST
260
+ publish - "running" must stop meaning "reachable". A bench whose container
261
+ was created (or recreated) without its port mapping otherwise reports
262
+ fully healthy here and is only ever discovered by a caller's HTTP request
263
+ failing. Silent when a host port IS published; ``ports`` is the bench's
264
+ OWN container port, already resolved by the caller.
265
+ """
266
+ if (
267
+ resolvers.resolve_host_web_url(frappe_container, resolved_path, assigned_ports=ports)
268
+ is not None
269
+ ):
270
+ return None
271
+ return Message(
272
+ "start.no_host_port",
273
+ f"'{project_name}' is running, but no host port is published for this bench's "
274
+ f"web server (container port {ports[0]}) - it is not reachable from outside the "
275
+ "container. Recreate the instance with 'cwcli init' to publish its ports.",
276
+ )
277
+
278
+
238
279
  def _launched_processes(frappe_container, bench_path, snapshot) -> list[ProcessLaunch]:
239
280
  """The launched set: every expected Procfile label, with its PID if already up.
240
281
 
@@ -1 +0,0 @@
1
- __version__ = "2.3.0"