caffeinated-whale-cli 2.3.0__tar.gz → 2.4.0__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.4.0}/PKG-INFO +12 -3
  2. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/README.md +11 -2
  3. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/pyproject.toml +1 -1
  4. caffeinated_whale_cli-2.4.0/src/caffeinated_whale_cli/__init__.py +1 -0
  5. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/axi.py +9 -1
  6. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/init.py +24 -4
  7. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/start.py +12 -2
  8. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/init.py +113 -28
  9. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/scale.py +12 -9
  10. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/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.4.0}/LICENSE +0 -0
  13. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/setup.cfg +0 -0
  14. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/__init__.py +0 -0
  15. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/apps.py +0 -0
  16. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/backup.py +0 -0
  17. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/config.py +0 -0
  18. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/console.html +0 -0
  19. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/doctor.py +0 -0
  20. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/inspect.py +0 -0
  21. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/label.py +0 -0
  22. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/list.py +0 -0
  23. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/logs.py +0 -0
  24. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/open.py +0 -0
  25. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/restart.py +0 -0
  26. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/restore.py +0 -0
  27. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/rm.py +0 -0
  28. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/rm_site.py +0 -0
  29. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/run.py +0 -0
  30. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/scale.py +0 -0
  31. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/self_update.py +0 -0
  32. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/serve.py +0 -0
  33. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/status.py +0 -0
  34. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/stop.py +0 -0
  35. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/unlock.py +0 -0
  36. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/update.py +0 -0
  37. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/utils.py +0 -0
  38. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/commands/where.py +0 -0
  39. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/__init__.py +0 -0
  40. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/apps.py +0 -0
  41. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/auto_inspect.py +0 -0
  42. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/backup.py +0 -0
  43. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/bench_ops.py +0 -0
  44. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/config.py +0 -0
  45. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/credbridge.py +0 -0
  46. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/docker.py +0 -0
  47. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/doctor.py +0 -0
  48. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/envelope.py +0 -0
  49. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/errors.py +0 -0
  50. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/exec_stream.py +0 -0
  51. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/fleet.py +0 -0
  52. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/inspect.py +0 -0
  53. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/label.py +0 -0
  54. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/list.py +0 -0
  55. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/logs.py +0 -0
  56. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/open.py +0 -0
  57. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/resolvers.py +0 -0
  58. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/restart.py +0 -0
  59. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/restore.py +0 -0
  60. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/rm.py +0 -0
  61. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/rm_site.py +0 -0
  62. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/run.py +0 -0
  63. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/status.py +0 -0
  64. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/stop.py +0 -0
  65. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/supervision.py +0 -0
  66. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/unlock.py +0 -0
  67. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/update.py +0 -0
  68. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/url.py +0 -0
  69. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/version.py +0 -0
  70. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/core/where.py +0 -0
  71. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/main.py +0 -0
  72. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/update_notice.py +0 -0
  73. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/__init__.py +0 -0
  74. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/agent_hooks.py +0 -0
  75. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/auto_inspect.py +0 -0
  76. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/bench_labels.py +0 -0
  77. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/bench_sites.py +0 -0
  78. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/cache.py +0 -0
  79. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/completion_utils.py +0 -0
  80. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/config_utils.py +0 -0
  81. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/console.py +0 -0
  82. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/db_utils.py +0 -0
  83. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/docker_utils.py +0 -0
  84. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/port_utils.py +0 -0
  85. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/sendme_utils.py +0 -0
  86. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/startup.py +0 -0
  87. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/tips.py +0 -0
  88. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/toon.py +0 -0
  89. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/src/caffeinated_whale_cli/utils/vscode_utils.py +0 -0
  90. {caffeinated_whale_cli-2.3.0 → caffeinated_whale_cli-2.4.0}/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.4.0
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
@@ -201,6 +201,7 @@ cwcli init [OPTIONS] [PROJECT_NAME]
201
201
  | `--bench-parent TEXT` | Directory inside container where bench is created (default: /workspace); also the container mount point for the persisted host `data/` directory, fixed for the life of the instance - changing it on a re-init errors, naming the mounted directory |
202
202
  | `--frappe-branch TEXT` | Frappe branch or tag for bench init, e.g. `version-16` or `v16.26.3` (default: version-16). Mutually exclusive with `--version` |
203
203
  | `--version TEXT` | Frappe version for bench init, resolved by shape: a bare major (`16` → `version-16` branch) or a full semantic version (`16.26.3` → `v16.26.3` tag). Malformed values are rejected with a non-zero exit. Mutually exclusive with `--frappe-branch` |
204
+ | `--frappe-url TEXT` | Custom Frappe repository URL to build from (a fork), passed to `bench init --frappe-path`. Omit to use the default `frappe/frappe` repo. Pair with `--frappe-branch` to check out the fork's branch. A private fork authenticates through the same credential bridge as `apps install`/`apps update` (host `gh`/`glab`, no token in the container); sign in on the host first |
204
205
  | `--db-root-password TEXT` | MariaDB root password (default: 123) |
205
206
  | `--admin-password TEXT` | Administrator password for the site, used verbatim. If omitted, a strong password is generated and printed once (interactive runs only); a non-interactive run must supply this flag |
206
207
  | `--install-erpnext` | Install ERPNext application after initialization |
@@ -271,6 +272,9 @@ cwcli init my-project --version 16
271
272
  # ...or a full semantic version -> vX.Y.Z tag
272
273
  cwcli init my-project --version 16.26.3
273
274
 
275
+ # Build from a Frappe fork, checking out its branch
276
+ cwcli init my-project --frappe-url https://github.com/me/frappe --frappe-branch my-feature
277
+
274
278
  # Full customization
275
279
  cwcli init my-project \
276
280
  --port 12000 \
@@ -320,15 +324,19 @@ Then open http://development.localhost:8000 (or `cwcli open my-project`).
320
324
  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
325
  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
326
  If the port cannot be read, start skips the wait and warns instead of probing another bench's port.
327
+ 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
328
 
324
329
  **Port Conflict Handling:**
325
330
 
326
- If the requested ports are in use, you'll see:
331
+ Init retries occupied ports five times, one second apart, to allow bindings from a just-removed instance to finish releasing.
332
+ Each retry is announced in both interactive and non-interactive runs.
333
+ If the ports remain occupied, init exits nonzero before creating the instance and names every conflicting port:
327
334
 
328
335
  ```
329
336
  Error: The following ports are already in use: 8000-8005
330
337
 
331
- Tip: Use the --port flag to select a different starting port.
338
+ Tip: If an instance using these ports was just removed, they may still be releasing - wait a few seconds and retry.
339
+ Tip: Otherwise, use the --port flag to select a different starting port.
332
340
  Example: cwcli init my-project --port 10000
333
341
  ```
334
342
 
@@ -498,6 +506,7 @@ cwcli start [OPTIONS] [PROJECT_NAME]...
498
506
  - **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
507
  - **Per-process supervision:** each Procfile process runs under supervisord, so one can be restarted or auto-healed without disturbing the others
500
508
  - **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`
509
+ - **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
510
  - **Port Conflict Detection:** Automatically checks if required ports are available
502
511
  - **Interactive Resolution:** Offers to stop conflicting Frappe projects (use `--yes` to auto-confirm)
503
512
  - **Process Identification:** Shows which processes are using ports (cross-platform)
@@ -161,6 +161,7 @@ cwcli init [OPTIONS] [PROJECT_NAME]
161
161
  | `--bench-parent TEXT` | Directory inside container where bench is created (default: /workspace); also the container mount point for the persisted host `data/` directory, fixed for the life of the instance - changing it on a re-init errors, naming the mounted directory |
162
162
  | `--frappe-branch TEXT` | Frappe branch or tag for bench init, e.g. `version-16` or `v16.26.3` (default: version-16). Mutually exclusive with `--version` |
163
163
  | `--version TEXT` | Frappe version for bench init, resolved by shape: a bare major (`16` → `version-16` branch) or a full semantic version (`16.26.3` → `v16.26.3` tag). Malformed values are rejected with a non-zero exit. Mutually exclusive with `--frappe-branch` |
164
+ | `--frappe-url TEXT` | Custom Frappe repository URL to build from (a fork), passed to `bench init --frappe-path`. Omit to use the default `frappe/frappe` repo. Pair with `--frappe-branch` to check out the fork's branch. A private fork authenticates through the same credential bridge as `apps install`/`apps update` (host `gh`/`glab`, no token in the container); sign in on the host first |
164
165
  | `--db-root-password TEXT` | MariaDB root password (default: 123) |
165
166
  | `--admin-password TEXT` | Administrator password for the site, used verbatim. If omitted, a strong password is generated and printed once (interactive runs only); a non-interactive run must supply this flag |
166
167
  | `--install-erpnext` | Install ERPNext application after initialization |
@@ -231,6 +232,9 @@ cwcli init my-project --version 16
231
232
  # ...or a full semantic version -> vX.Y.Z tag
232
233
  cwcli init my-project --version 16.26.3
233
234
 
235
+ # Build from a Frappe fork, checking out its branch
236
+ cwcli init my-project --frappe-url https://github.com/me/frappe --frappe-branch my-feature
237
+
234
238
  # Full customization
235
239
  cwcli init my-project \
236
240
  --port 12000 \
@@ -280,15 +284,19 @@ Then open http://development.localhost:8000 (or `cwcli open my-project`).
280
284
  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
285
  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
286
  If the port cannot be read, start skips the wait and warns instead of probing another bench's port.
287
+ 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
288
 
284
289
  **Port Conflict Handling:**
285
290
 
286
- If the requested ports are in use, you'll see:
291
+ Init retries occupied ports five times, one second apart, to allow bindings from a just-removed instance to finish releasing.
292
+ Each retry is announced in both interactive and non-interactive runs.
293
+ If the ports remain occupied, init exits nonzero before creating the instance and names every conflicting port:
287
294
 
288
295
  ```
289
296
  Error: The following ports are already in use: 8000-8005
290
297
 
291
- Tip: Use the --port flag to select a different starting port.
298
+ Tip: If an instance using these ports was just removed, they may still be releasing - wait a few seconds and retry.
299
+ Tip: Otherwise, use the --port flag to select a different starting port.
292
300
  Example: cwcli init my-project --port 10000
293
301
  ```
294
302
 
@@ -458,6 +466,7 @@ cwcli start [OPTIONS] [PROJECT_NAME]...
458
466
  - **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
467
  - **Per-process supervision:** each Procfile process runs under supervisord, so one can be restarted or auto-healed without disturbing the others
460
468
  - **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`
469
+ - **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
470
  - **Port Conflict Detection:** Automatically checks if required ports are available
462
471
  - **Interactive Resolution:** Offers to stop conflicting Frappe projects (use `--yes` to auto-confirm)
463
472
  - **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.4.0"
8
8
  authors = [
9
9
  { name = "Christopher McKay", email = "mckay.christopher73@outlook.com" },
10
10
  ]
@@ -0,0 +1 @@
1
+ __version__ = "2.4.0"
@@ -2157,6 +2157,13 @@ def axi_init(
2157
2157
  help="Frappe version resolved by shape: a bare major (16 -> version-16) or a full "
2158
2158
  "semantic version (16.26.3 -> v16.26.3). Mutually exclusive with --frappe-branch.",
2159
2159
  ),
2160
+ frappe_url: str = typer.Option(
2161
+ None,
2162
+ "--frappe-url",
2163
+ help="Custom Frappe repository URL to build from (a fork), passed to 'bench init "
2164
+ "--frappe-path'. Omit for the default frappe/frappe repo. Pair with --frappe-branch "
2165
+ "to check out the fork's branch.",
2166
+ ),
2160
2167
  db_root_password: str = typer.Option(
2161
2168
  None,
2162
2169
  "--db-root-password",
@@ -2283,6 +2290,7 @@ def axi_init(
2283
2290
  site_name=site,
2284
2291
  bench_parent=bench_parent,
2285
2292
  frappe_ref=frappe_ref,
2293
+ frappe_url=frappe_url,
2286
2294
  db_root_password=db_root,
2287
2295
  admin_password=resolved_admin,
2288
2296
  reuse_bench=reuse_bench,
@@ -2336,7 +2344,7 @@ def axi_init(
2336
2344
  )
2337
2345
  else:
2338
2346
  for warning in start_result.warnings:
2339
- if warning.code == "start.web_not_ready":
2347
+ if warning.code in ("start.web_not_ready", "start.no_host_port"):
2340
2348
  print(f"Warning: {warning.text}", file=sys.stderr, flush=True)
2341
2349
 
2342
2350
  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
@@ -478,6 +487,15 @@ def init(
478
487
  "(16.26.3 -> v16.26.3 tag). Mutually exclusive with --frappe-branch."
479
488
  ),
480
489
  ),
490
+ frappe_url: str | None = typer.Option(
491
+ None,
492
+ "--frappe-url",
493
+ help=(
494
+ "Custom Frappe repository URL to build from (a fork), passed to "
495
+ "'bench init --frappe-path'. Omit to use the default frappe/frappe repo. "
496
+ "Pair with --frappe-branch to check out the fork's branch."
497
+ ),
498
+ ),
481
499
  db_root_password: str = typer.Option(
482
500
  "123",
483
501
  "--db-root-password",
@@ -553,6 +571,7 @@ def init(
553
571
  cwcli init my-project --version 16 --install-erpnext
554
572
  cwcli init my-project --version 16.26.3
555
573
  cwcli init my-project --frappe-branch version-16 --admin-password mypass
574
+ cwcli init my-project --frappe-url https://github.com/me/frappe --frappe-branch my-feature
556
575
  """
557
576
  # Resolve the Frappe git ref first so a malformed --version fails fast,
558
577
  # before any project dir / container work.
@@ -635,6 +654,7 @@ def init(
635
654
  site_name=site,
636
655
  bench_parent=bench_parent,
637
656
  frappe_ref=frappe_branch,
657
+ frappe_url=frappe_url,
638
658
  db_root_password=db_root_password,
639
659
  admin_password=admin_password,
640
660
  reuse_bench=reuse_answer,
@@ -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]")
@@ -30,6 +30,7 @@ value the old path did not print.
30
30
 
31
31
  from __future__ import annotations
32
32
 
33
+ import contextlib
33
34
  import json
34
35
  import re
35
36
  import shlex
@@ -42,8 +43,8 @@ from pathlib import Path
42
43
 
43
44
  from ..utils import config_utils, db_utils
44
45
  from ..utils.port_utils import check_ports_in_use, format_port_list
46
+ from . import credbridge, resolvers
45
47
  from . import docker as core_docker
46
- from . import resolvers
47
48
  from .envelope import Choice, Message, Result, Status
48
49
  from .errors import CwcliError, ErrorKind
49
50
  from .exec_stream import ExecChunk, ExecDone, exec_stream
@@ -472,6 +473,43 @@ def _project_containers_running(project_name: str) -> bool:
472
473
 
473
474
  _EXPECTED_COMPOSE_SERVICES = frozenset({"frappe", "mariadb", "redis-cache", "redis-queue"})
474
475
 
476
+ # A just-removed instance's own port bindings can take a moment to fully
477
+ # release (Docker's docker-proxy teardown lag, observed in the field to clear
478
+ # within seconds), so a rapid remove-then-reinit (a CI purge-and-recreate
479
+ # pattern) can see those SAME ports as still "in use" right after the old
480
+ # instance is gone. 5 attempts a second apart absorbs that ordinary teardown
481
+ # window before concluding the conflict is real and persistent.
482
+ _PORT_CHECK_ATTEMPTS = 5
483
+ _PORT_CHECK_RETRY_DELAY = 1.0
484
+
485
+
486
+ def _ports_still_in_use(ports: list[int], *, emit: OnEvent) -> list[int]:
487
+ """Check host ports, retrying briefly before giving up.
488
+
489
+ Never silently treats a conflict as "no conflict" - it only delays the
490
+ verdict to absorb a transient teardown window. A port still in use after
491
+ every attempt is reported exactly as before: loudly, deterministically,
492
+ regardless of interactivity.
493
+ """
494
+ ports_in_use: list[int] = []
495
+ for attempt in range(_PORT_CHECK_ATTEMPTS):
496
+ port_status = check_ports_in_use(ports)
497
+ ports_in_use = [p for p, in_use in port_status.items() if in_use]
498
+ if not ports_in_use:
499
+ return []
500
+ if attempt < _PORT_CHECK_ATTEMPTS - 1:
501
+ emit(
502
+ InitNotice(
503
+ code="ports.retry",
504
+ text=(
505
+ f"Port(s) {format_port_list(ports_in_use)} still in use "
506
+ "(may be a just-removed instance releasing them); retrying..."
507
+ ),
508
+ )
509
+ )
510
+ time.sleep(_PORT_CHECK_RETRY_DELAY)
511
+ return ports_in_use
512
+
475
513
 
476
514
  def _running_compose_services(project_name: str) -> set[str]:
477
515
  """Return this project's running compose service names."""
@@ -532,14 +570,17 @@ def init_instance(
532
570
  if not auto_start and not _project_containers_running(project_name):
533
571
  web_ports = list(range(port, port + 6))
534
572
  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]
573
+ ports_in_use = _ports_still_in_use(web_ports + socketio_ports, emit=emit)
537
574
  if ports_in_use:
538
575
  raise CwcliError(
539
576
  ErrorKind.CONFLICT,
540
577
  "ports.in_use",
541
578
  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.",
579
+ hint=(
580
+ "If an instance using these ports was just removed, they may still be "
581
+ "releasing - wait a few seconds and retry. Otherwise, use --port to "
582
+ "select a different starting port."
583
+ ),
543
584
  )
544
585
 
545
586
  emit(InitStepStart(phase="project_dir", message="Creating project directory"))
@@ -581,11 +622,11 @@ def init_instance(
581
622
  )
582
623
  )
583
624
  content = compose_path.read_text()
584
- content = content.replace("8000-8005:8000-8005", f"{port}-{port+5}:8000-8005")
625
+ web_mapping = f"{port}-{port + 5}:8000-8005"
626
+ content = content.replace("8000-8005:8000-8005", web_mapping)
585
627
  socketio_start = port + 1000
586
- content = content.replace(
587
- "9000-9005:9000-9005", f"{socketio_start}-{socketio_start+5}:9000-9005"
588
- )
628
+ socketio_mapping = f"{socketio_start}-{socketio_start + 5}:9000-9005"
629
+ content = content.replace("9000-9005:9000-9005", socketio_mapping)
589
630
  # The upstream devcontainer template sets `working_dir: /workspace/development`
590
631
  # - a path cwcli never creates (its bench lives at /workspace/frappe-bench).
591
632
  # /workspace is a bind mount to CWCLI_HOME/projects/<name>/, so on `compose up`
@@ -617,6 +658,26 @@ def init_instance(
617
658
  (project_dir / "data").mkdir(parents=True, exist_ok=True)
618
659
  content = content.replace("- ..:/workspace:cached", f"- ../data:{bench_parent_path}:cached")
619
660
  content = content.replace("working_dir: /workspace", f"working_dir: {bench_parent_path}")
661
+ # frappe_docker removed the devcontainer template's ports block in favor
662
+ # of editor-managed forwarding. cwcli runs the compose project directly,
663
+ # so it must add the host publications when the downloaded template omits
664
+ # both mappings. Without them Docker starts a healthy but host-unreachable
665
+ # instance and every later string replacement remains a silent no-op.
666
+ if web_mapping not in content and socketio_mapping not in content:
667
+ working_dir = f" working_dir: {bench_parent_path}"
668
+ published_ports = (
669
+ f'{working_dir}\n ports:\n - "{web_mapping}"\n - "{socketio_mapping}"'
670
+ )
671
+ content = content.replace(working_dir, published_ports, 1)
672
+ if web_mapping not in content or socketio_mapping not in content:
673
+ compose_path.unlink()
674
+ raise CwcliError(
675
+ ErrorKind.PRECONDITION,
676
+ "compose.ports_missing",
677
+ "The downloaded Docker Compose template could not be configured "
678
+ "with the required host port mappings.",
679
+ hint="Retry init. If the error persists, report the upstream compose change.",
680
+ )
620
681
  compose_path.write_text(content)
621
682
 
622
683
  compose_base = ["docker", "compose", "-p", project_name, "-f", "docker-compose.yml"]
@@ -806,6 +867,7 @@ def init_bench(
806
867
  site_name: str,
807
868
  bench_parent: str = "/workspace",
808
869
  frappe_ref: str = DEFAULT_FRAPPE_BRANCH,
870
+ frappe_url: str | None = None,
809
871
  db_root_password: str = "123",
810
872
  admin_password: str,
811
873
  reuse_bench: bool | None = None,
@@ -827,6 +889,16 @@ def init_bench(
827
889
  it mirrors - the downloaded compose's hardcoded ``MYSQL_ROOT_PASSWORD:
828
890
  123`` - lives in the compose file the core itself downloads; it is
829
891
  shielded off the argv/echo, not randomized.
892
+
893
+ ``frappe_url`` is the optional custom Frappe repository (a fork) passed
894
+ through to ``bench init --frappe-path``; ``None`` (the default) builds from
895
+ the upstream ``frappe/frappe`` repo exactly as before. ``frappe_ref`` is the
896
+ branch/tag within that repo, so a fork tracking a custom branch pairs the two
897
+ (``frappe_url=<fork>``, ``frappe_ref=<branch>``). When ``frappe_url`` is a
898
+ PRIVATE fork, the ``bench init`` clone is wrapped in the same
899
+ :func:`core.credbridge.credential_bridge` context ``apps`` install/update
900
+ use, so it authenticates through the host's ``gh``/``glab`` with no token
901
+ ever entering the container.
830
902
  """
831
903
  emit = on_event or _noop
832
904
  warnings: list[Message] = []
@@ -981,31 +1053,44 @@ def init_bench(
981
1053
  else:
982
1054
  emit(InitTrace(text="yarn installed successfully."))
983
1055
 
1056
+ bench_init_args = [
1057
+ "bench",
1058
+ "init",
1059
+ "--skip-redis-config-generation",
1060
+ "--frappe-branch",
1061
+ shlex.quote(frappe_ref),
1062
+ ]
1063
+ # A custom Frappe repo (a fork) rides bench's own --frappe-path; frappe_ref
1064
+ # above is the branch/tag to check out within it. Omitted -> upstream repo.
1065
+ if frappe_url:
1066
+ bench_init_args += ["--frappe-path", shlex.quote(frappe_url)]
1067
+ bench_init_args += [shlex.quote(bench_name), "--verbose"]
984
1068
  bench_init_cmd = _build_cd_command(
985
- bench_parent_path,
986
- nvm_prefix
987
- + env_prefix
988
- + " ".join(
989
- [
990
- "bench",
991
- "init",
992
- "--skip-redis-config-generation",
993
- "--frappe-branch",
994
- shlex.quote(frappe_ref),
995
- shlex.quote(bench_name),
996
- "--verbose",
997
- ]
998
- ),
1069
+ bench_parent_path, nvm_prefix + env_prefix + " ".join(bench_init_args)
999
1070
  )
1000
1071
 
1001
1072
  emit(InitStepStart(phase="bench_init", item=bench_name))
1002
- _run_exec(
1003
- frappe_container,
1004
- bench_init_cmd,
1005
- phase="bench_init",
1006
- emit=emit,
1007
- collect=not stream_output,
1073
+ # A custom Frappe fork may be a PRIVATE repo, so bridge the container's
1074
+ # git back to the host's gh/glab for the clone `bench init` performs -
1075
+ # the same context apps install/update wrap their get-app fetch in
1076
+ # (see core.credbridge). The upstream default (frappe_url is None) is the
1077
+ # public frappe/frappe repo and needs no bridge, so that common path is
1078
+ # left untouched; the bridge is inert for a public fork too (git only
1079
+ # calls a credential helper on a 401). bench_full_path is under the
1080
+ # workspace mount, which is what credbridge resolves against.
1081
+ bridge: contextlib.AbstractContextManager[None] = (
1082
+ credbridge.credential_bridge(frappe_container, bench_full_path)
1083
+ if frappe_url
1084
+ else contextlib.nullcontext()
1008
1085
  )
1086
+ with bridge:
1087
+ _run_exec(
1088
+ frappe_container,
1089
+ bench_init_cmd,
1090
+ phase="bench_init",
1091
+ emit=emit,
1092
+ collect=not stream_output,
1093
+ )
1009
1094
  emit(InitStepEnd(phase="bench_init"))
1010
1095
  bench_created = True
1011
1096
 
@@ -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"