caffeinated-whale-cli 2.2.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.2.0 → caffeinated_whale_cli-2.3.1}/PKG-INFO +34 -15
  2. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/README.md +33 -14
  3. {caffeinated_whale_cli-2.2.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.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/apps.py +31 -11
  6. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/axi.py +35 -10
  7. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/init.py +13 -4
  8. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/start.py +12 -2
  9. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/apps.py +97 -8
  10. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/docker.py +2 -1
  11. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/errors.py +7 -0
  12. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/init.py +67 -7
  13. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/list.py +2 -1
  14. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/restart.py +5 -2
  15. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/rm.py +2 -1
  16. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/scale.py +12 -9
  17. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/start.py +48 -4
  18. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/status.py +5 -2
  19. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/stop.py +6 -2
  20. caffeinated_whale_cli-2.2.0/src/caffeinated_whale_cli/__init__.py +0 -1
  21. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/LICENSE +0 -0
  22. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/setup.cfg +0 -0
  23. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/__init__.py +0 -0
  24. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/backup.py +0 -0
  25. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/config.py +0 -0
  26. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/console.html +0 -0
  27. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/doctor.py +0 -0
  28. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/inspect.py +0 -0
  29. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/label.py +0 -0
  30. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/list.py +0 -0
  31. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/logs.py +0 -0
  32. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/open.py +0 -0
  33. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/restart.py +0 -0
  34. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/restore.py +0 -0
  35. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/rm.py +0 -0
  36. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/rm_site.py +0 -0
  37. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/run.py +0 -0
  38. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/scale.py +0 -0
  39. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/self_update.py +0 -0
  40. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/serve.py +0 -0
  41. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/status.py +0 -0
  42. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/stop.py +0 -0
  43. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/unlock.py +0 -0
  44. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/update.py +0 -0
  45. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/utils.py +0 -0
  46. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/commands/where.py +0 -0
  47. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/__init__.py +0 -0
  48. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/auto_inspect.py +0 -0
  49. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/backup.py +0 -0
  50. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/bench_ops.py +0 -0
  51. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/config.py +0 -0
  52. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/credbridge.py +0 -0
  53. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/doctor.py +0 -0
  54. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/envelope.py +0 -0
  55. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/exec_stream.py +0 -0
  56. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/fleet.py +0 -0
  57. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/inspect.py +0 -0
  58. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/label.py +0 -0
  59. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/logs.py +0 -0
  60. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/open.py +0 -0
  61. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/resolvers.py +0 -0
  62. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/restore.py +0 -0
  63. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/rm_site.py +0 -0
  64. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/run.py +0 -0
  65. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/supervision.py +0 -0
  66. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/unlock.py +0 -0
  67. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/update.py +0 -0
  68. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/url.py +0 -0
  69. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/version.py +0 -0
  70. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/core/where.py +0 -0
  71. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/main.py +0 -0
  72. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/update_notice.py +0 -0
  73. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/__init__.py +0 -0
  74. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/agent_hooks.py +0 -0
  75. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/auto_inspect.py +0 -0
  76. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/bench_labels.py +0 -0
  77. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/bench_sites.py +0 -0
  78. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/cache.py +0 -0
  79. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/completion_utils.py +0 -0
  80. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/config_utils.py +0 -0
  81. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/console.py +0 -0
  82. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/db_utils.py +0 -0
  83. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/docker_utils.py +0 -0
  84. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/port_utils.py +0 -0
  85. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/sendme_utils.py +0 -0
  86. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/startup.py +0 -0
  87. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/tips.py +0 -0
  88. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/toon.py +0 -0
  89. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.1}/src/caffeinated_whale_cli/utils/vscode_utils.py +0 -0
  90. {caffeinated_whale_cli-2.2.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.2.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)
@@ -1114,14 +1119,24 @@ cwcli apps checkout [OPTIONS] PROJECT_NAME APP REF
1114
1119
 
1115
1120
  **`apps list`** - lists apps available in the bench (live `ls apps/`); with `--installed`/`--site` it also lists the apps installed per site (all sites by default, grouped by site).
1116
1121
 
1117
- **`apps install`** - fetches (`bench get-app`, honoring `--branch`) and installs each app on the target site(s). Each `APP` is a known app name **or** a git URL (passed straight to `bench get-app`, so custom apps not in bench's registry work). `--fetch-only` fetches without installing on any site. A bench that is already running may still serve code it loaded before the install and fail to see the new app: install therefore **resynchronises** that bench - it restarts every cwcli-supervised program that runs the bench's Python (`web`, `schedule`, and every worker) and requires every changed site to answer Frappe before it reports success. Each restart is announced as it happens and the step is reported as a `restart-processes` row, and the command exits non-zero rather than claiming success for a site it could not confirm. A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy. `socketio`, `watch`, and the redis programs are deliberately left alone: they import no Frappe app, and cycling redis would drop the cache and the job queue for nothing. Restarting a worker does interrupt a job in flight, which is the honest cost of not leaving background jobs running code that no longer exists. A bench that was not running is left alone.
1122
+ **`apps install`** - ensures each app is present on the bench, then installs it on the target site(s).
1123
+ An app absent from `apps/` is fetched with `bench get-app` (honoring `--branch`); an app already present is not fetched again, so pre-warmed benches install it without failing on the existing directory.
1124
+ Each `APP` is a known app name **or** a git URL (passed straight to `bench get-app` when a fetch is needed, so custom apps not in bench's registry work).
1125
+ `--fetch-only` ensures the app is present without installing it on any site.
1126
+ `--if-not-present` makes install idempotent: an app already installed on a target site is skipped (reported, not reinstalled, with its install hooks never rerun) and the command still exits 0, so "ensure this app is installed" is a single call whose exit code a script can trust; an app the site does not have is still installed normally, and a genuine install failure still exits non-zero.
1127
+ A bench that is already running may still serve code it loaded before the install and fail to see the new app: install therefore **resynchronises** that bench - it restarts every cwcli-supervised program that runs the bench's Python (`web`, `schedule`, and every worker) and requires every changed site to answer Frappe before it reports success.
1128
+ Each restart is announced as it happens and the step is reported as a `restart-processes` row, and the command exits non-zero rather than claiming success for a site it could not confirm.
1129
+ A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy.
1130
+ `socketio`, `watch`, and the redis programs are deliberately left alone: they import no Frappe app, and cycling redis would drop the cache and the job queue for nothing.
1131
+ Restarting a worker does interrupt a job in flight, which is the honest cost of not leaving background jobs running code that no longer exists.
1132
+ A bench that was not running is left alone.
1118
1133
 
1119
1134
  **`apps uninstall`** - removes each app from the target site(s) (`bench --site <site> uninstall-app`). This destroys site data, so it is gated by `-y`/`--yes` or an interactive confirmation (a non-TTY without `--yes` refuses). It uses the same resynchronise-and-verify step as install, so no supervised process - web, scheduler, or worker - can keep running against the code and tables of an app that is gone.
1120
1135
 
1121
1136
  **`apps update`** - the canonical app-update path (what the deprecated `cwcli update` now delegates to). Updating the `frappe` framework app runs `bench update --reset`; other apps use the normal git-pull + migrate flow. After the migrations finish and maintenance mode is lifted, it runs the same **resynchronise** step as `apps install`, so a `git pull` cannot leave the bench's web, scheduler, and workers on the code that was there before it; every migrated site must answer Frappe before the run reports success. `--site` narrows which affected sites are migrated; if none of the named site(s) actually have the app installed, the command refuses and exits non-zero rather than silently migrating nothing (a genuine typo/mismatch guard - a bench with no affected sites at all still exits zero). It accepts the same migration flags as the [deprecated `update` command](#update---update-apps-and-migrate) (`--clear-cache`, `--clear-website-cache`, `--build`, `--skip-maintenance`, `--no-recache`). When updating the `frappe` framework app the flow runs the bench-wide `bench update --reset`, so `--site` and those per-app migration flags do not apply and are reported as ignored.
1122
1137
 
1123
1138
  **`apps checkout`** - fetches and checks out an arbitrary branch, tag, or commit (`REF`) into an app that is **already present** in the bench (`apps/<app>`), so a specific feature branch can be put under test in the instance the app lives in.
1124
- Unlike `apps install` (a fresh `bench get-app` clone) and `apps update` (the tracked upstream on every app), this targets one existing checkout: it runs `git fetch <remote> <ref>` then `git checkout -B <ref> FETCH_HEAD` in the app directory (the remote is auto-detected - `upstream` for a bench-installed app, `origin` for a hand-cloned one).
1139
+ Unlike `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app), this targets one existing checkout: it runs `git fetch <remote> <ref>` then `git checkout -B <ref> FETCH_HEAD` in the app directory (the remote is auto-detected - `upstream` for a bench-installed app, `origin` for a hand-cloned one).
1125
1140
  A **dirty working tree is refused** before anything is fetched, so uncommitted work in the in-instance checkout is never carried across a branch switch.
1126
1141
  Once the checkout step moves the tree, the command runs the same **resynchronise** step `apps install` uses, even if a later `--reset` step fails.
1127
1142
  Swapping the code under a running bench and leaving it serving the branch you just moved off is the quietest form of that defect, because nothing errors at all.
@@ -2224,16 +2239,19 @@ cwcli axi apps update frappe-one erpnext
2224
2239
  cwcli axi apps update frappe-one frappe # runs 'bench update --reset'
2225
2240
 
2226
2241
  # Put ONE branch, tag, or commit under test in an app already in the bench -
2227
- # the gap 'apps install' (a fresh clone) and 'apps update' (the tracked
2242
+ # the gap 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
2228
2243
  # upstream) leave. Add --reset to force a clean tree at the fetched ref.
2229
2244
  cwcli axi apps checkout frappe-one myapp feature/new-thing
2230
2245
  cwcli axi apps checkout frappe-one myapp feature/new-thing --reset
2231
2246
 
2232
- # Fetch and install ONE app on ONE named site. --site is required (there is no
2247
+ # Ensure and install ONE app on ONE named site. --site is required (there is no
2233
2248
  # fan-out here), and an app already installed on that site is refused rather
2234
- # than re-installed over its existing data.
2249
+ # than reinstalled over its existing data unless --if-not-present requests a skip.
2235
2250
  cwcli axi apps install frappe-one hrms --site erp.localhost
2236
2251
  cwcli axi apps install frappe-one https://github.com/me/myapp --site erp.localhost --branch develop
2252
+ # Idempotent: skip (do not reinstall) an app already on the site and exit 0, so a
2253
+ # CI step can trust the exit code. A real install failure still exits non-zero.
2254
+ cwcli axi apps install frappe-one hrms --site erp.localhost --if-not-present
2237
2255
 
2238
2256
  # Provision a new instance, bench, and site; the report prints as ONE TOON
2239
2257
  # document. BLOCKS for the full 10-20 minute run (like axi apps update) and
@@ -2278,9 +2296,9 @@ Use `cwcli open`'s banner for the host address and `cwcli status` for the HTTP o
2278
2296
 
2279
2297
  `cwcli axi apps list` is the read that answers what is *on* the bench `cwcli axi benches` names: the bench's available apps, and with `--installed`/`--site` which apps are installed on which site (only the app name, never the version column `bench list-apps` prints). A site whose read FAILED is reported as `null` and exits `1`, never as an empty list - "has no apps" and "could not tell" are different facts, and only one of them is safe to act on. Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start` (exit 2). **`cwcli axi apps uninstall` deliberately does not exist:** letting an agent drop the tables of a real site is a product decision that deserves its own evidence, not something settled as a side effect of moving code onto the logic core. Use the human `cwcli apps uninstall` (it has `--json` and honest exit codes) until that decision is taken. `cwcli axi apps install` was held under that same shared rationale and now exists, scoped to the half of it that rationale never covered - see below.
2280
2298
 
2281
- `cwcli axi apps checkout <project> <app> <ref>` puts ONE named branch, tag, or commit under test in an app that is already in the bench - the gap `apps install` (a fresh `bench get-app` clone) and `apps update` (the tracked upstream on every app) leave. It emits the same per-step TOON report as the other `apps` verbs, one row per git step, and its exit code reads that report's `ok`, so a refused checkout exits `1` rather than looking like a success. **It existed before `apps install` did and while `apps uninstall` still does not, and that was deliberate rather than inconsistent:** their shared deferral names one threat, an agent destroying site data (uninstalling an app drops its tables), while a checkout runs `git fetch` then `git checkout -B` inside the app's source directory - no bench command, no site, no SQL. Its guards each protect against something named: there is no `--yes` (an agent must not start containers you deliberately stopped, so a stopped project is a usage error pointing at `cwcli start`); the app must already be a git checkout, so a typo'd name errors instead of silently doing nothing or implying an install; and a **dirty working tree is refused outright**, before anything is fetched, so your uncommitted edits are never carried across a branch switch. That refusal is deliberately **stronger than git's own**: git blocks only a checkout that would overwrite a modified file, which used to let a non-conflicting edit ride silently onto another ref. It matters here because these checkouts live in a shared dev instance, where the work carried across may not even be yours. Be precise about what counts as dirty: **anything `git status --porcelain` reports - staged changes, unstaged modifications to tracked files, and untracked files** - refuses. Untracked files count because a new module written but not yet added is uncommitted work, and it is exactly the case where the tool must not decide for you that the file is worthless; `.gitignore`d build residue is not reported by git at all, so it never blocks a checkout. `--reset` is the explicit opt-in through the refusal: it is the one destructive flag, is never implied, and is reported as its own row in the output; it discards tracked local edits in the container's copy of the app, which is what the post-merge step of an app delivery workflow wants. It does **not** delete untracked files - cwcli never runs `git clean` - so it lets the checkout proceed and leaves them in place. Private-repo fetches use the same credential bridge as `apps install`/`apps update`, so no token is stored in the container. One current limit: the report does not tell you which commit you landed on - that is deliberately left to a future read verb rather than bolted onto the shared mutation report.
2299
+ `cwcli axi apps checkout <project> <app> <ref>` puts ONE named branch, tag, or commit under test in an app that is already in the bench - the gap `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app) leave. It emits the same per-step TOON report as the other `apps` verbs, one row per git step, and its exit code reads that report's `ok`, so a refused checkout exits `1` rather than looking like a success. **It existed before `apps install` did and while `apps uninstall` still does not, and that was deliberate rather than inconsistent:** their shared deferral names one threat, an agent destroying site data (uninstalling an app drops its tables), while a checkout runs `git fetch` then `git checkout -B` inside the app's source directory - no bench command, no site, no SQL. Its guards each protect against something named: there is no `--yes` (an agent must not start containers you deliberately stopped, so a stopped project is a usage error pointing at `cwcli start`); the app must already be a git checkout, so a typo'd name errors instead of silently doing nothing or implying an install; and a **dirty working tree is refused outright**, before anything is fetched, so your uncommitted edits are never carried across a branch switch. That refusal is deliberately **stronger than git's own**: git blocks only a checkout that would overwrite a modified file, which used to let a non-conflicting edit ride silently onto another ref. It matters here because these checkouts live in a shared dev instance, where the work carried across may not even be yours. Be precise about what counts as dirty: **anything `git status --porcelain` reports - staged changes, unstaged modifications to tracked files, and untracked files** - refuses. Untracked files count because a new module written but not yet added is uncommitted work, and it is exactly the case where the tool must not decide for you that the file is worthless; `.gitignore`d build residue is not reported by git at all, so it never blocks a checkout. `--reset` is the explicit opt-in through the refusal: it is the one destructive flag, is never implied, and is reported as its own row in the output; it discards tracked local edits in the container's copy of the app, which is what the post-merge step of an app delivery workflow wants. It does **not** delete untracked files - cwcli never runs `git clean` - so it lets the checkout proceed and leaves them in place. Private-repo fetches use the same credential bridge as `apps install`/`apps update`, so no token is stored in the container. One current limit: the report does not tell you which commit you landed on - that is deliberately left to a future read verb rather than bolted onto the shared mutation report.
2282
2300
 
2283
- `cwcli axi apps install <project> <app-or-git-url> --site <site>` fetches an app into the bench and installs it on one named site.
2301
+ `cwcli axi apps install <project> <app-or-git-url> --site <site>` ensures the app is present on the bench, fetching it only when absent, and installs it on one named site.
2284
2302
  Installing an app is the first step of essentially any Frappe app work, and until this verb existed it was the one routine operation with no agent-surface form, so an agent had to drop to the raw human command for it.
2285
2303
  It is deliberately narrower than the human `cwcli apps install`, in exactly two ways, because the original deferral of `apps install`/`apps uninstall` named a real threat - an agent destroying site data - that covers `uninstall` unconditionally but covers `install` only in one case.
2286
2304
  Installing an app a site does **not** have creates that app's own tables and touches no other app's data; installing over an app the site **already** has re-runs that app's install hooks against rows that already exist.
@@ -2290,14 +2308,15 @@ So the verb ships scoped to the first case and refuses the second, rather than b
2290
2308
  The human verb installs on every site on the bench when you omit it; an unqualified fan-out is how an agent reaches a site nobody named, so on this surface the target is always explicit.
2291
2309
  That follows `cwcli axi run-tests`, which requires its site for the same reason: `install-app` runs the app's `after_install`, which is arbitrary Python from the repository being installed, against a live database, and when the effect is unbounded, defaulting the target is the wrong default.
2292
2310
 
2293
- **An app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2311
+ **By default, an app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2294
2312
  The refusal names what to do instead: `cwcli axi apps checkout` to move the app to another ref, `cwcli axi apps update` to pull and migrate it, or the human `cwcli apps install` for a genuine reinstall.
2295
2313
  A site whose installed-app list cannot be *read* is refused too (`app.install_state_unknown`), because an unreadable state must never be treated as "nothing is installed there".
2296
- There is deliberately **no flag to bypass this**: a `--force` here has no beneficiary in the workflow the verb serves (install, check out a ref, migrate, test), and its mere existence invites its use.
2297
- The escape hatch is the human verb, which is where a human confirms a reinstall.
2314
+ There is deliberately **no flag to bypass this by reinstalling**: a `--force` that re-ran the install hooks over existing data has no beneficiary in the workflow the verb serves (install, check out a ref, migrate, test), and its mere existence invites its use.
2315
+ The escape hatch for a genuine reinstall is the human verb, which is where a human confirms one.
2298
2316
 
2299
- Note that the already-installed case is **not** reported as an idempotent exit-`0` no-op, even though the agent surface generally treats an already-satisfied desired state as a success.
2300
- The desired state here is "installed from this branch", and cwcli cannot confirm the copy already on the site matches the `--branch` you asked for, so exiting `0` would be asserting something it has not verified.
2317
+ By default the already-installed case is **not** reported as an idempotent exit-`0` no-op, even though the agent surface generally treats an already-satisfied desired state as a success: the desired state here is "installed from this branch", and cwcli cannot confirm the copy already on the site matches the `--branch` you asked for, so a silent exit `0` would be asserting something it has not verified.
2318
+ `--if-not-present` opts into the idempotent reading for a caller that just wants the app present: an app already installed on the site is then **skipped** (reported as a `skip-install` row, with its install hooks never rerun) and the verb exits `0`, so a CI step can trust the exit code instead of swallowing every failure with `|| true`; an app the site lacks is still installed, and a genuine install failure still exits non-zero.
2319
+ It is not a `--force`: there is still no way to reinstall over an app the site already has.
2301
2320
  Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start`.
2302
2321
  Private-repo fetches use the same credential bridge as `apps update`/`apps checkout`, so no token is stored in the container.
2303
2322
  On an already-serving bench, the operation resynchronises that bench - it restarts every supervised program running the bench's Python (`web`, `schedule`, workers) and reports a `restart-processes` row.
@@ -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)
@@ -1074,14 +1079,24 @@ cwcli apps checkout [OPTIONS] PROJECT_NAME APP REF
1074
1079
 
1075
1080
  **`apps list`** - lists apps available in the bench (live `ls apps/`); with `--installed`/`--site` it also lists the apps installed per site (all sites by default, grouped by site).
1076
1081
 
1077
- **`apps install`** - fetches (`bench get-app`, honoring `--branch`) and installs each app on the target site(s). Each `APP` is a known app name **or** a git URL (passed straight to `bench get-app`, so custom apps not in bench's registry work). `--fetch-only` fetches without installing on any site. A bench that is already running may still serve code it loaded before the install and fail to see the new app: install therefore **resynchronises** that bench - it restarts every cwcli-supervised program that runs the bench's Python (`web`, `schedule`, and every worker) and requires every changed site to answer Frappe before it reports success. Each restart is announced as it happens and the step is reported as a `restart-processes` row, and the command exits non-zero rather than claiming success for a site it could not confirm. A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy. `socketio`, `watch`, and the redis programs are deliberately left alone: they import no Frappe app, and cycling redis would drop the cache and the job queue for nothing. Restarting a worker does interrupt a job in flight, which is the honest cost of not leaving background jobs running code that no longer exists. A bench that was not running is left alone.
1082
+ **`apps install`** - ensures each app is present on the bench, then installs it on the target site(s).
1083
+ An app absent from `apps/` is fetched with `bench get-app` (honoring `--branch`); an app already present is not fetched again, so pre-warmed benches install it without failing on the existing directory.
1084
+ Each `APP` is a known app name **or** a git URL (passed straight to `bench get-app` when a fetch is needed, so custom apps not in bench's registry work).
1085
+ `--fetch-only` ensures the app is present without installing it on any site.
1086
+ `--if-not-present` makes install idempotent: an app already installed on a target site is skipped (reported, not reinstalled, with its install hooks never rerun) and the command still exits 0, so "ensure this app is installed" is a single call whose exit code a script can trust; an app the site does not have is still installed normally, and a genuine install failure still exits non-zero.
1087
+ A bench that is already running may still serve code it loaded before the install and fail to see the new app: install therefore **resynchronises** that bench - it restarts every cwcli-supervised program that runs the bench's Python (`web`, `schedule`, and every worker) and requires every changed site to answer Frappe before it reports success.
1088
+ Each restart is announced as it happens and the step is reported as a `restart-processes` row, and the command exits non-zero rather than claiming success for a site it could not confirm.
1089
+ A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy.
1090
+ `socketio`, `watch`, and the redis programs are deliberately left alone: they import no Frappe app, and cycling redis would drop the cache and the job queue for nothing.
1091
+ Restarting a worker does interrupt a job in flight, which is the honest cost of not leaving background jobs running code that no longer exists.
1092
+ A bench that was not running is left alone.
1078
1093
 
1079
1094
  **`apps uninstall`** - removes each app from the target site(s) (`bench --site <site> uninstall-app`). This destroys site data, so it is gated by `-y`/`--yes` or an interactive confirmation (a non-TTY without `--yes` refuses). It uses the same resynchronise-and-verify step as install, so no supervised process - web, scheduler, or worker - can keep running against the code and tables of an app that is gone.
1080
1095
 
1081
1096
  **`apps update`** - the canonical app-update path (what the deprecated `cwcli update` now delegates to). Updating the `frappe` framework app runs `bench update --reset`; other apps use the normal git-pull + migrate flow. After the migrations finish and maintenance mode is lifted, it runs the same **resynchronise** step as `apps install`, so a `git pull` cannot leave the bench's web, scheduler, and workers on the code that was there before it; every migrated site must answer Frappe before the run reports success. `--site` narrows which affected sites are migrated; if none of the named site(s) actually have the app installed, the command refuses and exits non-zero rather than silently migrating nothing (a genuine typo/mismatch guard - a bench with no affected sites at all still exits zero). It accepts the same migration flags as the [deprecated `update` command](#update---update-apps-and-migrate) (`--clear-cache`, `--clear-website-cache`, `--build`, `--skip-maintenance`, `--no-recache`). When updating the `frappe` framework app the flow runs the bench-wide `bench update --reset`, so `--site` and those per-app migration flags do not apply and are reported as ignored.
1082
1097
 
1083
1098
  **`apps checkout`** - fetches and checks out an arbitrary branch, tag, or commit (`REF`) into an app that is **already present** in the bench (`apps/<app>`), so a specific feature branch can be put under test in the instance the app lives in.
1084
- Unlike `apps install` (a fresh `bench get-app` clone) and `apps update` (the tracked upstream on every app), this targets one existing checkout: it runs `git fetch <remote> <ref>` then `git checkout -B <ref> FETCH_HEAD` in the app directory (the remote is auto-detected - `upstream` for a bench-installed app, `origin` for a hand-cloned one).
1099
+ Unlike `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app), this targets one existing checkout: it runs `git fetch <remote> <ref>` then `git checkout -B <ref> FETCH_HEAD` in the app directory (the remote is auto-detected - `upstream` for a bench-installed app, `origin` for a hand-cloned one).
1085
1100
  A **dirty working tree is refused** before anything is fetched, so uncommitted work in the in-instance checkout is never carried across a branch switch.
1086
1101
  Once the checkout step moves the tree, the command runs the same **resynchronise** step `apps install` uses, even if a later `--reset` step fails.
1087
1102
  Swapping the code under a running bench and leaving it serving the branch you just moved off is the quietest form of that defect, because nothing errors at all.
@@ -2184,16 +2199,19 @@ cwcli axi apps update frappe-one erpnext
2184
2199
  cwcli axi apps update frappe-one frappe # runs 'bench update --reset'
2185
2200
 
2186
2201
  # Put ONE branch, tag, or commit under test in an app already in the bench -
2187
- # the gap 'apps install' (a fresh clone) and 'apps update' (the tracked
2202
+ # the gap 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
2188
2203
  # upstream) leave. Add --reset to force a clean tree at the fetched ref.
2189
2204
  cwcli axi apps checkout frappe-one myapp feature/new-thing
2190
2205
  cwcli axi apps checkout frappe-one myapp feature/new-thing --reset
2191
2206
 
2192
- # Fetch and install ONE app on ONE named site. --site is required (there is no
2207
+ # Ensure and install ONE app on ONE named site. --site is required (there is no
2193
2208
  # fan-out here), and an app already installed on that site is refused rather
2194
- # than re-installed over its existing data.
2209
+ # than reinstalled over its existing data unless --if-not-present requests a skip.
2195
2210
  cwcli axi apps install frappe-one hrms --site erp.localhost
2196
2211
  cwcli axi apps install frappe-one https://github.com/me/myapp --site erp.localhost --branch develop
2212
+ # Idempotent: skip (do not reinstall) an app already on the site and exit 0, so a
2213
+ # CI step can trust the exit code. A real install failure still exits non-zero.
2214
+ cwcli axi apps install frappe-one hrms --site erp.localhost --if-not-present
2197
2215
 
2198
2216
  # Provision a new instance, bench, and site; the report prints as ONE TOON
2199
2217
  # document. BLOCKS for the full 10-20 minute run (like axi apps update) and
@@ -2238,9 +2256,9 @@ Use `cwcli open`'s banner for the host address and `cwcli status` for the HTTP o
2238
2256
 
2239
2257
  `cwcli axi apps list` is the read that answers what is *on* the bench `cwcli axi benches` names: the bench's available apps, and with `--installed`/`--site` which apps are installed on which site (only the app name, never the version column `bench list-apps` prints). A site whose read FAILED is reported as `null` and exits `1`, never as an empty list - "has no apps" and "could not tell" are different facts, and only one of them is safe to act on. Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start` (exit 2). **`cwcli axi apps uninstall` deliberately does not exist:** letting an agent drop the tables of a real site is a product decision that deserves its own evidence, not something settled as a side effect of moving code onto the logic core. Use the human `cwcli apps uninstall` (it has `--json` and honest exit codes) until that decision is taken. `cwcli axi apps install` was held under that same shared rationale and now exists, scoped to the half of it that rationale never covered - see below.
2240
2258
 
2241
- `cwcli axi apps checkout <project> <app> <ref>` puts ONE named branch, tag, or commit under test in an app that is already in the bench - the gap `apps install` (a fresh `bench get-app` clone) and `apps update` (the tracked upstream on every app) leave. It emits the same per-step TOON report as the other `apps` verbs, one row per git step, and its exit code reads that report's `ok`, so a refused checkout exits `1` rather than looking like a success. **It existed before `apps install` did and while `apps uninstall` still does not, and that was deliberate rather than inconsistent:** their shared deferral names one threat, an agent destroying site data (uninstalling an app drops its tables), while a checkout runs `git fetch` then `git checkout -B` inside the app's source directory - no bench command, no site, no SQL. Its guards each protect against something named: there is no `--yes` (an agent must not start containers you deliberately stopped, so a stopped project is a usage error pointing at `cwcli start`); the app must already be a git checkout, so a typo'd name errors instead of silently doing nothing or implying an install; and a **dirty working tree is refused outright**, before anything is fetched, so your uncommitted edits are never carried across a branch switch. That refusal is deliberately **stronger than git's own**: git blocks only a checkout that would overwrite a modified file, which used to let a non-conflicting edit ride silently onto another ref. It matters here because these checkouts live in a shared dev instance, where the work carried across may not even be yours. Be precise about what counts as dirty: **anything `git status --porcelain` reports - staged changes, unstaged modifications to tracked files, and untracked files** - refuses. Untracked files count because a new module written but not yet added is uncommitted work, and it is exactly the case where the tool must not decide for you that the file is worthless; `.gitignore`d build residue is not reported by git at all, so it never blocks a checkout. `--reset` is the explicit opt-in through the refusal: it is the one destructive flag, is never implied, and is reported as its own row in the output; it discards tracked local edits in the container's copy of the app, which is what the post-merge step of an app delivery workflow wants. It does **not** delete untracked files - cwcli never runs `git clean` - so it lets the checkout proceed and leaves them in place. Private-repo fetches use the same credential bridge as `apps install`/`apps update`, so no token is stored in the container. One current limit: the report does not tell you which commit you landed on - that is deliberately left to a future read verb rather than bolted onto the shared mutation report.
2259
+ `cwcli axi apps checkout <project> <app> <ref>` puts ONE named branch, tag, or commit under test in an app that is already in the bench - the gap `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app) leave. It emits the same per-step TOON report as the other `apps` verbs, one row per git step, and its exit code reads that report's `ok`, so a refused checkout exits `1` rather than looking like a success. **It existed before `apps install` did and while `apps uninstall` still does not, and that was deliberate rather than inconsistent:** their shared deferral names one threat, an agent destroying site data (uninstalling an app drops its tables), while a checkout runs `git fetch` then `git checkout -B` inside the app's source directory - no bench command, no site, no SQL. Its guards each protect against something named: there is no `--yes` (an agent must not start containers you deliberately stopped, so a stopped project is a usage error pointing at `cwcli start`); the app must already be a git checkout, so a typo'd name errors instead of silently doing nothing or implying an install; and a **dirty working tree is refused outright**, before anything is fetched, so your uncommitted edits are never carried across a branch switch. That refusal is deliberately **stronger than git's own**: git blocks only a checkout that would overwrite a modified file, which used to let a non-conflicting edit ride silently onto another ref. It matters here because these checkouts live in a shared dev instance, where the work carried across may not even be yours. Be precise about what counts as dirty: **anything `git status --porcelain` reports - staged changes, unstaged modifications to tracked files, and untracked files** - refuses. Untracked files count because a new module written but not yet added is uncommitted work, and it is exactly the case where the tool must not decide for you that the file is worthless; `.gitignore`d build residue is not reported by git at all, so it never blocks a checkout. `--reset` is the explicit opt-in through the refusal: it is the one destructive flag, is never implied, and is reported as its own row in the output; it discards tracked local edits in the container's copy of the app, which is what the post-merge step of an app delivery workflow wants. It does **not** delete untracked files - cwcli never runs `git clean` - so it lets the checkout proceed and leaves them in place. Private-repo fetches use the same credential bridge as `apps install`/`apps update`, so no token is stored in the container. One current limit: the report does not tell you which commit you landed on - that is deliberately left to a future read verb rather than bolted onto the shared mutation report.
2242
2260
 
2243
- `cwcli axi apps install <project> <app-or-git-url> --site <site>` fetches an app into the bench and installs it on one named site.
2261
+ `cwcli axi apps install <project> <app-or-git-url> --site <site>` ensures the app is present on the bench, fetching it only when absent, and installs it on one named site.
2244
2262
  Installing an app is the first step of essentially any Frappe app work, and until this verb existed it was the one routine operation with no agent-surface form, so an agent had to drop to the raw human command for it.
2245
2263
  It is deliberately narrower than the human `cwcli apps install`, in exactly two ways, because the original deferral of `apps install`/`apps uninstall` named a real threat - an agent destroying site data - that covers `uninstall` unconditionally but covers `install` only in one case.
2246
2264
  Installing an app a site does **not** have creates that app's own tables and touches no other app's data; installing over an app the site **already** has re-runs that app's install hooks against rows that already exist.
@@ -2250,14 +2268,15 @@ So the verb ships scoped to the first case and refuses the second, rather than b
2250
2268
  The human verb installs on every site on the bench when you omit it; an unqualified fan-out is how an agent reaches a site nobody named, so on this surface the target is always explicit.
2251
2269
  That follows `cwcli axi run-tests`, which requires its site for the same reason: `install-app` runs the app's `after_install`, which is arbitrary Python from the repository being installed, against a live database, and when the effect is unbounded, defaulting the target is the wrong default.
2252
2270
 
2253
- **An app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2271
+ **By default, an app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2254
2272
  The refusal names what to do instead: `cwcli axi apps checkout` to move the app to another ref, `cwcli axi apps update` to pull and migrate it, or the human `cwcli apps install` for a genuine reinstall.
2255
2273
  A site whose installed-app list cannot be *read* is refused too (`app.install_state_unknown`), because an unreadable state must never be treated as "nothing is installed there".
2256
- There is deliberately **no flag to bypass this**: a `--force` here has no beneficiary in the workflow the verb serves (install, check out a ref, migrate, test), and its mere existence invites its use.
2257
- The escape hatch is the human verb, which is where a human confirms a reinstall.
2274
+ There is deliberately **no flag to bypass this by reinstalling**: a `--force` that re-ran the install hooks over existing data has no beneficiary in the workflow the verb serves (install, check out a ref, migrate, test), and its mere existence invites its use.
2275
+ The escape hatch for a genuine reinstall is the human verb, which is where a human confirms one.
2258
2276
 
2259
- Note that the already-installed case is **not** reported as an idempotent exit-`0` no-op, even though the agent surface generally treats an already-satisfied desired state as a success.
2260
- The desired state here is "installed from this branch", and cwcli cannot confirm the copy already on the site matches the `--branch` you asked for, so exiting `0` would be asserting something it has not verified.
2277
+ By default the already-installed case is **not** reported as an idempotent exit-`0` no-op, even though the agent surface generally treats an already-satisfied desired state as a success: the desired state here is "installed from this branch", and cwcli cannot confirm the copy already on the site matches the `--branch` you asked for, so a silent exit `0` would be asserting something it has not verified.
2278
+ `--if-not-present` opts into the idempotent reading for a caller that just wants the app present: an app already installed on the site is then **skipped** (reported as a `skip-install` row, with its install hooks never rerun) and the verb exits `0`, so a CI step can trust the exit code instead of swallowing every failure with `|| true`; an app the site lacks is still installed, and a genuine install failure still exits non-zero.
2279
+ It is not a `--force`: there is still no way to reinstall over an app the site already has.
2261
2280
  Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start`.
2262
2281
  Private-repo fetches use the same credential bridge as `apps update`/`apps checkout`, so no token is stored in the container.
2263
2282
  On an already-serving bench, the operation resynchronises that bench - it restarts every supervised program running the bench's Python (`web`, `schedule`, workers) and reports a `restart-processes` row.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "caffeinated-whale-cli"
7
- version = "2.2.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"
@@ -53,6 +53,9 @@ _ANNOUNCE = {
53
53
  "install-app": lambda app_name, site: (
54
54
  f"[bold cyan]Installing[/bold cyan] {app_name} on [magenta]{site}[/magenta]..."
55
55
  ),
56
+ "skip-install": lambda app_name, site: (
57
+ f"[dim]Skipping[/dim] {app_name} on [magenta]{site}[/magenta] (already installed)..."
58
+ ),
56
59
  "uninstall-app": lambda app_name, site: (
57
60
  f"[bold cyan]Uninstalling[/bold cyan] {app_name} from [magenta]{site}[/magenta]..."
58
61
  ),
@@ -267,7 +270,9 @@ def install_apps(
267
270
  project_name: str = typer.Argument(
268
271
  ..., help="The Docker Compose project name.", autocompletion=complete_project_names
269
272
  ),
270
- apps: list[str] = typer.Argument(..., help="App name(s) or git URL(s) to fetch and install."),
273
+ apps: list[str] = typer.Argument(
274
+ ..., help="App name(s) or git URL(s) to ensure on the bench and install."
275
+ ),
271
276
  bench: str = typer.Option(
272
277
  None, "--bench", help="Which bench to target: its numeric index or label."
273
278
  ),
@@ -283,16 +288,28 @@ def install_apps(
283
288
  "--fetch-only",
284
289
  help="Fetch the app(s) into the bench without installing on any site.",
285
290
  ),
291
+ if_not_present: bool = typer.Option(
292
+ False,
293
+ "--if-not-present",
294
+ help=(
295
+ "Idempotent: skip (do not re-install) an app already installed on a "
296
+ "target site instead of re-running its install hooks. A skipped app is "
297
+ "reported and the command still exits 0."
298
+ ),
299
+ ),
286
300
  json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
287
301
  yes: bool = typer.Option(
288
302
  False, "--yes", "-y", help="Auto-start stopped containers without prompting."
289
303
  ),
290
304
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output."),
291
305
  ):
292
- """Fetch (bench get-app) and install app(s) on the target site(s).
306
+ """Ensure app(s) are present on the bench, then install them on the target site(s).
293
307
 
294
- Each app is a known app name OR a git URL (passed straight to bench get-app).
308
+ Apps absent from apps/ are fetched with bench get-app; apps already present skip
309
+ that fetch. Each app is a known app name OR a git URL.
295
310
  Multi-site by default: with no --site the app is installed on every site.
311
+ With --if-not-present an app already installed on a target site is skipped rather
312
+ than re-installed, so "ensure this app is installed" is a single idempotent call.
296
313
  """
297
314
  ensure_containers_running(project_name, require_running=True, verbose=verbose, auto_start=yes)
298
315
  resolved = _resolve_bench(project_name, bench, bench_path, verbose)
@@ -305,6 +322,7 @@ def install_apps(
305
322
  sites=sites,
306
323
  branch=branch,
307
324
  fetch_only=fetch_only,
325
+ if_not_present=if_not_present,
308
326
  on_event=_make_renderer(json_output=json_output, verbose=verbose),
309
327
  )
310
328
  except CwcliError as e:
@@ -322,13 +340,15 @@ def install_apps(
322
340
  _refresh_cache(project_name, verbose)
323
341
 
324
342
  # The banner must match what actually happened: only claim "installed" when an
325
- # install-app step ran (not for --fetch-only or a bench with no sites).
326
- installed = any(r.action == "install-app" for r in report.results)
327
- _report_and_exit(
328
- report,
329
- json_output,
330
- success_msg="App(s) installed." if installed else "App(s) fetched.",
331
- )
343
+ # install-app step ran (not for --fetch-only, a bench with no sites, or an
344
+ # --if-not-present run that only skipped already-installed apps).
345
+ if any(r.action == "install-app" for r in report.results):
346
+ success_msg = "App(s) installed."
347
+ elif any(r.action == "skip-install" for r in report.results):
348
+ success_msg = "App(s) already installed; nothing to do."
349
+ else:
350
+ success_msg = "App(s) fetched."
351
+ _report_and_exit(report, json_output, success_msg=success_msg)
332
352
 
333
353
 
334
354
  # ------------------------------------------------------------------------ uninstall
@@ -450,7 +470,7 @@ def checkout_app(
450
470
  ):
451
471
  """Fetch and check out a branch/ref into an app already installed in the instance.
452
472
 
453
- Unlike 'apps install' (a fresh get-app clone) and 'apps update' (the tracked
473
+ Unlike 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
454
474
  upstream on every app), this puts a specific feature branch, tag, or commit
455
475
  under test in the EXISTING apps/<app> checkout, authenticated for private repos
456
476
  through the same credential bridge as install/update. Use --reset to force a
@@ -1563,7 +1563,7 @@ def axi_apps_checkout(
1563
1563
  ) -> None:
1564
1564
  """Fetch and check out a ref into an app already in the bench; emit the report as TOON.
1565
1565
 
1566
- The gap `apps install` (a fresh get-app clone) and `apps update` (the tracked
1566
+ The gap `apps install` (bench acquisition and site installation) and `apps update` (the tracked
1567
1567
  upstream on every app) leave: putting ONE named branch, tag, or commit under
1568
1568
  test in the EXISTING apps/<app> checkout. There is no `axi run`, so this is
1569
1569
  the only agent-surface route to that step.
@@ -1665,15 +1665,27 @@ def axi_apps_install(
1665
1665
  # Named `app_name` because `app` is this module's Typer instance; the metavar
1666
1666
  # keeps the agent-visible usage line matching the human `cwcli apps install`.
1667
1667
  app_name: str = typer.Argument(
1668
- ..., metavar="APP", help="App name or git URL to fetch and install."
1668
+ ..., metavar="APP", help="App name or git URL to ensure on the bench and install."
1669
1669
  ),
1670
1670
  site: str = typer.Option(
1671
1671
  ..., "--site", help="The single site to install on. Required: there is no fan-out here."
1672
1672
  ),
1673
1673
  bench: str = typer.Option(None, "--bench", help="Which bench: numeric index or label."),
1674
1674
  branch: str = typer.Option(None, "--branch", help="Git branch to fetch (passed to get-app)."),
1675
+ if_not_present: bool = typer.Option(
1676
+ False,
1677
+ "--if-not-present",
1678
+ help=(
1679
+ "Idempotent: if the app is already installed on the site, skip it and "
1680
+ "exit 0 instead of refusing. Does NOT re-install or re-run install "
1681
+ "hooks - it only reports the app as already present."
1682
+ ),
1683
+ ),
1675
1684
  ) -> None:
1676
- """Fetch and install ONE app on ONE named site; emit the report as TOON.
1685
+ """Ensure and install ONE app on ONE named site; emit the report as TOON.
1686
+
1687
+ An app absent from apps/ is fetched with bench get-app; an app already present
1688
+ skips that fetch and proceeds to the site installation.
1677
1689
 
1678
1690
  Installing an app is the first step of essentially any Frappe app work, and
1679
1691
  without this verb it was the one routine operation with no agent-surface form,
@@ -1711,11 +1723,21 @@ def axi_apps_install(
1711
1723
  `axi migrate`'s absent `--skip-maintenance`. The escape hatch is the human verb,
1712
1724
  which is where a human confirms a reinstall.
1713
1725
 
1714
- The already-installed case is deliberately NOT reported as an idempotent exit-0
1715
- no-op, against the general AXI rule that an already-satisfied desired state is a
1716
- success. The desired state here is "installed FROM this branch", and cwcli
1717
- cannot confirm the copy already on the site matches the requested `--branch`, so
1718
- exit 0 would assert something it has not verified.
1726
+ The already-installed case is, BY DEFAULT, deliberately NOT reported as an
1727
+ idempotent exit-0 no-op, against the general AXI rule that an already-satisfied
1728
+ desired state is a success. The desired state here is "installed FROM this
1729
+ branch", and cwcli cannot confirm the copy already on the site matches the
1730
+ requested `--branch`, so a silent exit 0 would assert something it has not
1731
+ verified.
1732
+
1733
+ `--if-not-present` opts INTO the idempotent reading for a caller that just wants
1734
+ the app present: an app already installed on the site is then SKIPPED (reported
1735
+ as an `ok` `skip-install` row, its install hooks NOT re-run) and the verb exits
1736
+ 0. It is not a bypass of the safety the refusal guards - it never re-installs
1737
+ over existing data - it is the honest "ensure installed" answer, so a CI step can
1738
+ trust the exit code (a genuine install failure still exits 1) instead of
1739
+ swallowing every failure with `|| true`. It is NOT a `--force`: there is still
1740
+ no way to make the verb re-run install hooks over an app the site already has.
1719
1741
 
1720
1742
  NO --yes and no auto-start: a stopped project is a usage error naming
1721
1743
  `cwcli start`, as every bench-scoped axi verb already does. The private-repo
@@ -1731,7 +1753,10 @@ def axi_apps_install(
1731
1753
  sites=[site],
1732
1754
  branch=branch,
1733
1755
  auto_start=False,
1734
- require_absent=True,
1756
+ # --if-not-present is the opt-in idempotent path; without it the
1757
+ # already-installed refusal (require_absent) stays the default.
1758
+ require_absent=not if_not_present,
1759
+ if_not_present=if_not_present,
1735
1760
  on_event=_checkout_narrate,
1736
1761
  )
1737
1762
  except CwcliError as error:
@@ -2311,7 +2336,7 @@ def axi_init(
2311
2336
  )
2312
2337
  else:
2313
2338
  for warning in start_result.warnings:
2314
- if warning.code == "start.web_not_ready":
2339
+ if warning.code in ("start.web_not_ready", "start.no_host_port"):
2315
2340
  print(f"Warning: {warning.text}", file=sys.stderr, flush=True)
2316
2341
 
2317
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]")