caffeinated-whale-cli 2.2.0__tar.gz → 2.3.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.2.0 → caffeinated_whale_cli-2.3.0}/PKG-INFO +27 -13
  2. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/README.md +26 -12
  3. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/pyproject.toml +1 -1
  4. caffeinated_whale_cli-2.3.0/src/caffeinated_whale_cli/__init__.py +1 -0
  5. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/apps.py +31 -11
  6. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/axi.py +34 -9
  7. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/apps.py +97 -8
  8. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/docker.py +2 -1
  9. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/errors.py +7 -0
  10. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/list.py +2 -1
  11. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/restart.py +5 -2
  12. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/rm.py +2 -1
  13. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/start.py +5 -2
  14. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/status.py +5 -2
  15. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/stop.py +6 -2
  16. caffeinated_whale_cli-2.2.0/src/caffeinated_whale_cli/__init__.py +0 -1
  17. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/LICENSE +0 -0
  18. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/setup.cfg +0 -0
  19. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/__init__.py +0 -0
  20. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/backup.py +0 -0
  21. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/config.py +0 -0
  22. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/console.html +0 -0
  23. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/doctor.py +0 -0
  24. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/init.py +0 -0
  25. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/inspect.py +0 -0
  26. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/label.py +0 -0
  27. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/list.py +0 -0
  28. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/logs.py +0 -0
  29. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/open.py +0 -0
  30. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/restart.py +0 -0
  31. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/restore.py +0 -0
  32. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/rm.py +0 -0
  33. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/rm_site.py +0 -0
  34. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/run.py +0 -0
  35. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/scale.py +0 -0
  36. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/self_update.py +0 -0
  37. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/serve.py +0 -0
  38. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/start.py +0 -0
  39. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/status.py +0 -0
  40. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/stop.py +0 -0
  41. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/unlock.py +0 -0
  42. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/update.py +0 -0
  43. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/utils.py +0 -0
  44. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/commands/where.py +0 -0
  45. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/__init__.py +0 -0
  46. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/auto_inspect.py +0 -0
  47. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/backup.py +0 -0
  48. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/bench_ops.py +0 -0
  49. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/config.py +0 -0
  50. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/credbridge.py +0 -0
  51. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/doctor.py +0 -0
  52. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/envelope.py +0 -0
  53. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/exec_stream.py +0 -0
  54. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/fleet.py +0 -0
  55. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/init.py +0 -0
  56. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/inspect.py +0 -0
  57. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/label.py +0 -0
  58. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/logs.py +0 -0
  59. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/open.py +0 -0
  60. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/resolvers.py +0 -0
  61. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/restore.py +0 -0
  62. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/rm_site.py +0 -0
  63. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/run.py +0 -0
  64. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/scale.py +0 -0
  65. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/supervision.py +0 -0
  66. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/unlock.py +0 -0
  67. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/update.py +0 -0
  68. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/url.py +0 -0
  69. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/version.py +0 -0
  70. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/core/where.py +0 -0
  71. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/main.py +0 -0
  72. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/update_notice.py +0 -0
  73. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/__init__.py +0 -0
  74. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/agent_hooks.py +0 -0
  75. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/auto_inspect.py +0 -0
  76. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/bench_labels.py +0 -0
  77. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/bench_sites.py +0 -0
  78. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/cache.py +0 -0
  79. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/completion_utils.py +0 -0
  80. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/config_utils.py +0 -0
  81. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/console.py +0 -0
  82. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/db_utils.py +0 -0
  83. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/docker_utils.py +0 -0
  84. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/port_utils.py +0 -0
  85. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/sendme_utils.py +0 -0
  86. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/startup.py +0 -0
  87. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/tips.py +0 -0
  88. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/toon.py +0 -0
  89. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.0}/src/caffeinated_whale_cli/utils/vscode_utils.py +0 -0
  90. {caffeinated_whale_cli-2.2.0 → caffeinated_whale_cli-2.3.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.2.0
3
+ Version: 2.3.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
@@ -1114,14 +1114,24 @@ cwcli apps checkout [OPTIONS] PROJECT_NAME APP REF
1114
1114
 
1115
1115
  **`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
1116
 
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.
1117
+ **`apps install`** - ensures each app is present on the bench, then installs it on the target site(s).
1118
+ 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.
1119
+ 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).
1120
+ `--fetch-only` ensures the app is present without installing it on any site.
1121
+ `--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.
1122
+ 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.
1123
+ 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.
1124
+ A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy.
1125
+ `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.
1126
+ 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.
1127
+ A bench that was not running is left alone.
1118
1128
 
1119
1129
  **`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
1130
 
1121
1131
  **`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
1132
 
1123
1133
  **`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).
1134
+ 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
1135
  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
1136
  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
1137
  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 +2234,19 @@ cwcli axi apps update frappe-one erpnext
2224
2234
  cwcli axi apps update frappe-one frappe # runs 'bench update --reset'
2225
2235
 
2226
2236
  # 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
2237
+ # the gap 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
2228
2238
  # upstream) leave. Add --reset to force a clean tree at the fetched ref.
2229
2239
  cwcli axi apps checkout frappe-one myapp feature/new-thing
2230
2240
  cwcli axi apps checkout frappe-one myapp feature/new-thing --reset
2231
2241
 
2232
- # Fetch and install ONE app on ONE named site. --site is required (there is no
2242
+ # Ensure and install ONE app on ONE named site. --site is required (there is no
2233
2243
  # fan-out here), and an app already installed on that site is refused rather
2234
- # than re-installed over its existing data.
2244
+ # than reinstalled over its existing data unless --if-not-present requests a skip.
2235
2245
  cwcli axi apps install frappe-one hrms --site erp.localhost
2236
2246
  cwcli axi apps install frappe-one https://github.com/me/myapp --site erp.localhost --branch develop
2247
+ # Idempotent: skip (do not reinstall) an app already on the site and exit 0, so a
2248
+ # CI step can trust the exit code. A real install failure still exits non-zero.
2249
+ cwcli axi apps install frappe-one hrms --site erp.localhost --if-not-present
2237
2250
 
2238
2251
  # Provision a new instance, bench, and site; the report prints as ONE TOON
2239
2252
  # document. BLOCKS for the full 10-20 minute run (like axi apps update) and
@@ -2278,9 +2291,9 @@ Use `cwcli open`'s banner for the host address and `cwcli status` for the HTTP o
2278
2291
 
2279
2292
  `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
2293
 
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.
2294
+ `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
2295
 
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.
2296
+ `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
2297
  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
2298
  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
2299
  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 +2303,15 @@ So the verb ships scoped to the first case and refuses the second, rather than b
2290
2303
  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
2304
  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
2305
 
2293
- **An app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2306
+ **By default, an app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2294
2307
  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
2308
  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.
2309
+ 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.
2310
+ The escape hatch for a genuine reinstall is the human verb, which is where a human confirms one.
2298
2311
 
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.
2312
+ 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.
2313
+ `--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.
2314
+ It is not a `--force`: there is still no way to reinstall over an app the site already has.
2301
2315
  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
2316
  Private-repo fetches use the same credential bridge as `apps update`/`apps checkout`, so no token is stored in the container.
2303
2317
  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.
@@ -1074,14 +1074,24 @@ cwcli apps checkout [OPTIONS] PROJECT_NAME APP REF
1074
1074
 
1075
1075
  **`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
1076
 
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.
1077
+ **`apps install`** - ensures each app is present on the bench, then installs it on the target site(s).
1078
+ 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.
1079
+ 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).
1080
+ `--fetch-only` ensures the app is present without installing it on any site.
1081
+ `--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.
1082
+ 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.
1083
+ 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.
1084
+ A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy.
1085
+ `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.
1086
+ 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.
1087
+ A bench that was not running is left alone.
1078
1088
 
1079
1089
  **`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
1090
 
1081
1091
  **`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
1092
 
1083
1093
  **`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).
1094
+ 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
1095
  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
1096
  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
1097
  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 +2194,19 @@ cwcli axi apps update frappe-one erpnext
2184
2194
  cwcli axi apps update frappe-one frappe # runs 'bench update --reset'
2185
2195
 
2186
2196
  # 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
2197
+ # the gap 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
2188
2198
  # upstream) leave. Add --reset to force a clean tree at the fetched ref.
2189
2199
  cwcli axi apps checkout frappe-one myapp feature/new-thing
2190
2200
  cwcli axi apps checkout frappe-one myapp feature/new-thing --reset
2191
2201
 
2192
- # Fetch and install ONE app on ONE named site. --site is required (there is no
2202
+ # Ensure and install ONE app on ONE named site. --site is required (there is no
2193
2203
  # fan-out here), and an app already installed on that site is refused rather
2194
- # than re-installed over its existing data.
2204
+ # than reinstalled over its existing data unless --if-not-present requests a skip.
2195
2205
  cwcli axi apps install frappe-one hrms --site erp.localhost
2196
2206
  cwcli axi apps install frappe-one https://github.com/me/myapp --site erp.localhost --branch develop
2207
+ # Idempotent: skip (do not reinstall) an app already on the site and exit 0, so a
2208
+ # CI step can trust the exit code. A real install failure still exits non-zero.
2209
+ cwcli axi apps install frappe-one hrms --site erp.localhost --if-not-present
2197
2210
 
2198
2211
  # Provision a new instance, bench, and site; the report prints as ONE TOON
2199
2212
  # document. BLOCKS for the full 10-20 minute run (like axi apps update) and
@@ -2238,9 +2251,9 @@ Use `cwcli open`'s banner for the host address and `cwcli status` for the HTTP o
2238
2251
 
2239
2252
  `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
2253
 
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.
2254
+ `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
2255
 
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.
2256
+ `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
2257
  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
2258
  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
2259
  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 +2263,15 @@ So the verb ships scoped to the first case and refuses the second, rather than b
2250
2263
  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
2264
  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
2265
 
2253
- **An app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2266
+ **By default, an app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
2254
2267
  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
2268
  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.
2269
+ 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.
2270
+ The escape hatch for a genuine reinstall is the human verb, which is where a human confirms one.
2258
2271
 
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.
2272
+ 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.
2273
+ `--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.
2274
+ It is not a `--force`: there is still no way to reinstall over an app the site already has.
2261
2275
  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
2276
  Private-repo fetches use the same credential bridge as `apps update`/`apps checkout`, so no token is stored in the container.
2263
2277
  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.0"
8
8
  authors = [
9
9
  { name = "Christopher McKay", email = "mckay.christopher73@outlook.com" },
10
10
  ]
@@ -0,0 +1 @@
1
+ __version__ = "2.3.0"
@@ -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:
@@ -74,7 +74,7 @@ class AppResult:
74
74
 
75
75
  app: str
76
76
  site: str | None # None for the bench-wide get-app step
77
- action: str # "get-app" | "install-app" | "uninstall-app" | "restart-processes" | a git step
77
+ action: str # "get-app" | "install-app" | "skip-install" | "uninstall-app" | "restart-processes" | a git step
78
78
  ok: bool
79
79
 
80
80
 
@@ -535,6 +535,41 @@ def _refuse_if_installed(
535
535
  )
536
536
 
537
537
 
538
+ def _read_installed_by_site(
539
+ frappe_container,
540
+ path: str,
541
+ sites: list[str],
542
+ *,
543
+ emit: OnEvent,
544
+ ) -> dict[str, set[str]]:
545
+ """Each target site's installed-apps set. FAILS CLOSED on an unreadable site.
546
+
547
+ Backs the idempotent ``if_not_present`` install path. A site whose ``list-apps``
548
+ read fails cannot be confirmed clean, so it refuses (PRECONDITION) rather than
549
+ proceeding on an unknown - the same fail-honest rule ``_refuse_if_installed``
550
+ applies, so "could not read the site" never degrades to "the app is not there"
551
+ and gets silently (re)installed.
552
+ """
553
+ installed: dict[str, set[str]] = {}
554
+ for site in sites:
555
+ command, ok, site_apps = _installed_apps(frappe_container, path, site)
556
+ emit(AppsCommand(command=command))
557
+ if not ok:
558
+ raise CwcliError(
559
+ ErrorKind.PRECONDITION,
560
+ "app.install_state_unknown",
561
+ f"Could not read the installed apps on site '{site}' (it may not exist "
562
+ "on this bench), so it cannot be confirmed whether the app is already "
563
+ "installed there.",
564
+ hint=(
565
+ f"Check the site exists and is readable: 'cwcli axi apps list "
566
+ f"<project> --site {site}'."
567
+ ),
568
+ )
569
+ installed[site] = set(site_apps)
570
+ return installed
571
+
572
+
538
573
  def install_apps(
539
574
  project_name: str,
540
575
  apps: list[str],
@@ -546,10 +581,17 @@ def install_apps(
546
581
  fetch_only: bool = False,
547
582
  auto_start: bool = False,
548
583
  require_absent: bool = False,
584
+ if_not_present: bool = False,
549
585
  on_event: OnEvent | None = None,
550
586
  ) -> Result[AppsReport]:
551
587
  """Fetch (``bench get-app``) and install app(s) on the target site(s).
552
588
 
589
+ The fetch is idempotent on bench-presence: an app already present under the
590
+ bench's ``apps/`` (e.g. a bench built from a pre-warmed base that carries it)
591
+ would make ``bench get-app`` fail on the existing directory, so it is skipped
592
+ and the install-app phase proceeds. This is distinct from ``require_absent``
593
+ below, which is a per-SITE guard, not a per-bench one.
594
+
553
595
  ``require_absent`` refuses, BEFORE fetching anything, if an app is already
554
596
  installed on a target site. It defaults off so the human verb is unchanged, and
555
597
  the rule lives here rather than in a frontend so ``axi`` and any future GUI share
@@ -560,6 +602,18 @@ def install_apps(
560
602
  The check FAILS CLOSED: a site whose ``list-apps`` read fails cannot be confirmed
561
603
  clean, so it refuses rather than proceeding on an unknown (``core.where``'s
562
604
  fail-honest rule - an unreadable state must never degrade to "nothing is there").
605
+
606
+ ``if_not_present`` is the idempotent reading of "install": an app already
607
+ installed on a target site is SKIPPED (reported as an ``ok`` ``skip-install``
608
+ result, never re-installed) instead of refused, so a caller that just wants the
609
+ app present succeeds (exit 0) and can still trust the exit code for a genuine
610
+ failure. It is the opt-in alternative to ``require_absent``'s refusal: the two
611
+ answer the same "already installed on the site" case oppositely, so a frontend
612
+ passes at most one. It does NOT re-run install hooks for a skipped app (that is
613
+ exactly what the refusal guarded against), and it FAILS CLOSED on an unreadable
614
+ site the same way - "could not read" is never treated as "not installed". An app
615
+ absent from a given site still installs there normally, so a multi-site run
616
+ skips only the sites that already have it.
563
617
  """
564
618
  emit: OnEvent = on_event or _noop
565
619
 
@@ -580,14 +634,29 @@ def install_apps(
580
634
  # so every git-URL fetch is wrapped and torn down; see core.credbridge.
581
635
  with credbridge.credential_bridge(frappe_container, path):
582
636
  for target in apps:
637
+ app_name = derive_app_name(target)
638
+ command, before_apps = _available_apps(frappe_container, path)
639
+ before = set(before_apps)
640
+
641
+ # Idempotent fetch: if the app is already on the BENCH (its apps/ dir
642
+ # exists), `bench get-app` fails on the existing directory, which used to
643
+ # fail the whole install of an app a pre-warmed base already carries. Skip
644
+ # the fetch and install what is present. This is bench-presence, DISTINCT
645
+ # from the per-site installed-apps guard (`_installed_apps` /
646
+ # `_refuse_if_installed`): that gates a double SITE-install, this gates the
647
+ # bench FETCH. The install-app phase below is unchanged either way.
648
+ if app_name in before:
649
+ emit(AppsCommand(command=command))
650
+ results.append(AppResult(app=app_name, site=None, action="get-app", ok=True))
651
+ fetched.append((target, app_name))
652
+ continue
653
+
583
654
  get_cmd = f"bench get-app {branch_arg}{shlex.quote(target)}"
584
655
 
585
- # Announce BEFORE the apps/ read, so --verbose stderr keeps its historical
586
- # order: "Fetching x..." then the read's echo then get-app's own echo.
656
+ # Announce BEFORE the apps/ read echo, so --verbose stderr keeps its
657
+ # historical order: "Fetching x..." then the read's echo then get-app's own.
587
658
  emit(AppsAnnounce(phase="get-app", app=target))
588
- command, before_apps = _available_apps(frappe_container, path)
589
659
  emit(AppsCommand(command=command))
590
- before = set(before_apps)
591
660
 
592
661
  code = _run_step(
593
662
  frappe_container, get_cmd, path, emit=emit, phase="get-app", app=target
@@ -601,7 +670,7 @@ def install_apps(
601
670
  emit(AppsCommand(command=command))
602
671
 
603
672
  new_dirs = set(after_apps) - before
604
- app_name = new_dirs.pop() if len(new_dirs) == 1 else derive_app_name(target)
673
+ app_name = new_dirs.pop() if len(new_dirs) == 1 else app_name
605
674
  fetched.append((target, app_name))
606
675
 
607
676
  if not fetch_only:
@@ -613,8 +682,25 @@ def install_apps(
613
682
  "no sites on the bench to install on; app(s) fetched only.",
614
683
  )
615
684
  )
685
+ # Idempotent path: read each target site's installed set ONCE (fail-closed),
686
+ # so an app already installed on a site is skipped below instead of having
687
+ # its install hooks re-run against that site's existing data.
688
+ installed_by_site: dict[str, set[str]] = (
689
+ _read_installed_by_site(frappe_container, path, target_sites, emit=emit)
690
+ if if_not_present and target_sites
691
+ else {}
692
+ )
616
693
  for _target, app_name in fetched:
617
694
  for site in target_sites:
695
+ if if_not_present and app_name in installed_by_site.get(site, set()):
696
+ # Already present on this site: report it and move on. NOT an
697
+ # install-app step, so it drives no resync (nothing changed) and
698
+ # never re-runs the app's install hooks.
699
+ emit(AppsAnnounce(phase="skip-install", app=app_name, site=site))
700
+ results.append(
701
+ AppResult(app=app_name, site=site, action="skip-install", ok=True)
702
+ )
703
+ continue
618
704
  install_cmd = (
619
705
  f"bench --site {shlex.quote(site)} install-app {shlex.quote(app_name)}"
620
706
  )
@@ -631,6 +717,8 @@ def install_apps(
631
717
  results.append(
632
718
  AppResult(app=app_name, site=site, action="install-app", ok=code == 0)
633
719
  )
720
+ if if_not_present and code == 0:
721
+ installed_by_site.setdefault(site, set()).add(app_name)
634
722
 
635
723
  # Only the sites an install actually landed on: a site whose install-app failed
636
724
  # was not changed, so it has nothing to re-verify and must not fail the restart.
@@ -753,8 +841,9 @@ def checkout_app(
753
841
  ) -> Result[AppsReport]:
754
842
  """Fetch and check out an arbitrary ``ref`` into an app that ALREADY EXISTS.
755
843
 
756
- The gap ``install``/``update`` leave: ``install`` is ``bench get-app`` (a FRESH
757
- clone of a new app) and ``update`` is ``bench update --pull`` (the TRACKED
844
+ The gap ``install``/``update`` leave: ``install`` acquires an app with ``bench
845
+ get-app`` when it is absent and installs it on sites, while ``update`` uses
846
+ ``bench update --pull`` against the TRACKED
758
847
  upstream on every app). Neither fetches one named branch/tag/commit into an
759
848
  existing ``apps/<app>`` checkout, which is exactly what putting a feature branch
760
849
  under test in the instance the app lives in needs.
@@ -22,7 +22,7 @@ import subprocess
22
22
  import docker
23
23
  from docker.errors import DockerException
24
24
 
25
- from .errors import CwcliError, ErrorKind
25
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
26
26
 
27
27
  _COMPOSE_INSTALL_HINT = (
28
28
  "Install the Docker Compose v2 plugin: it ships with Docker Desktop, or "
@@ -332,6 +332,7 @@ def get_frappe_container(project_name: str):
332
332
  ErrorKind.DOCKER,
333
333
  "docker.unreachable",
334
334
  "Could not connect to Docker daemon.",
335
+ hint=DOCKER_UNREACHABLE_HINT,
335
336
  )
336
337
 
337
338
  if not containers:
@@ -28,6 +28,13 @@ class ErrorKind(Enum):
28
28
  INTERNAL = "internal" # unexpected
29
29
 
30
30
 
31
+ # The one next-step string for every "could not connect to the Docker daemon"
32
+ # CwcliError, so every frontend (axi included) renders the same actionable
33
+ # `help:` line instead of a bare error with no remedy. Mirrors the fix string
34
+ # `core/doctor.py`'s own daemon check already carries.
35
+ DOCKER_UNREACHABLE_HINT = "start Docker (Docker Desktop, or `systemctl start docker`)"
36
+
37
+
31
38
  class CwcliError(Exception):
32
39
  """A hard failure the core cannot recover from.
33
40
 
@@ -15,7 +15,7 @@ import docker
15
15
  from docker.errors import DockerException
16
16
 
17
17
  from .envelope import Result, Status
18
- from .errors import CwcliError, ErrorKind
18
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
19
19
 
20
20
 
21
21
  @dataclass(frozen=True, slots=True, kw_only=True)
@@ -64,6 +64,7 @@ def list_instances(*, service_name: str = "frappe") -> Result[list[InstanceDTO]]
64
64
  ErrorKind.DOCKER,
65
65
  "docker.unreachable",
66
66
  "Could not connect to Docker daemon.",
67
+ hint=DOCKER_UNREACHABLE_HINT,
67
68
  detail={"output": str(e)},
68
69
  ) from e
69
70
 
@@ -23,7 +23,7 @@ from docker.errors import APIError, NotFound
23
23
  from . import resolvers, supervision
24
24
  from .docker import get_project_containers
25
25
  from .envelope import Choice, Message, Result, Status
26
- from .errors import CwcliError, ErrorKind
26
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
27
27
 
28
28
 
29
29
  @dataclass(frozen=True, slots=True, kw_only=True)
@@ -52,7 +52,10 @@ def restart_process(
52
52
  containers = get_project_containers(project_name)
53
53
  if containers is None:
54
54
  raise CwcliError(
55
- ErrorKind.DOCKER, "docker.unreachable", "Could not connect to Docker daemon."
55
+ ErrorKind.DOCKER,
56
+ "docker.unreachable",
57
+ "Could not connect to Docker daemon.",
58
+ hint=DOCKER_UNREACHABLE_HINT,
56
59
  )
57
60
  if not containers:
58
61
  raise CwcliError(
@@ -50,7 +50,7 @@ from ..utils import bench_sites, db_utils
50
50
  from ..utils.config_utils import PROJECTS_DIR, cwcli_home
51
51
  from .docker import get_project_containers, get_project_networks, get_project_volumes
52
52
  from .envelope import Result, Status
53
- from .errors import CwcliError, ErrorKind
53
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
54
54
  from .resolvers import DEFAULT_BENCH_PATH
55
55
 
56
56
  # Marker in a Frappe backup filename that identifies the database dump - the one
@@ -839,6 +839,7 @@ def remove(
839
839
  ErrorKind.DOCKER,
840
840
  "docker.unreachable",
841
841
  f"Could not connect to Docker to inspect '{project_name}'.",
842
+ hint=DOCKER_UNREACHABLE_HINT,
842
843
  )
843
844
 
844
845
  project_dir = PROJECTS_DIR / project_name
@@ -28,7 +28,7 @@ from dataclasses import dataclass
28
28
  from . import resolvers, supervision
29
29
  from .docker import align_container_user_to_host, get_project_containers
30
30
  from .envelope import Message, Result, Status
31
- from .errors import CwcliError, ErrorKind
31
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
32
32
 
33
33
  # Shell metacharacters rejected in the bench path (it is interpolated into the
34
34
  # launch shell command; command-injection guard, mirrors core.backup).
@@ -93,7 +93,10 @@ def start(
93
93
  containers = get_project_containers(project_name)
94
94
  if containers is None:
95
95
  raise CwcliError(
96
- ErrorKind.DOCKER, "docker.unreachable", "Could not connect to Docker daemon."
96
+ ErrorKind.DOCKER,
97
+ "docker.unreachable",
98
+ "Could not connect to Docker daemon.",
99
+ hint=DOCKER_UNREACHABLE_HINT,
97
100
  )
98
101
  if not containers:
99
102
  raise CwcliError(
@@ -102,7 +102,7 @@ from docker.errors import APIError, NotFound
102
102
  from . import resolvers, supervision
103
103
  from .docker import get_project_containers
104
104
  from .envelope import Message, Result, Status
105
- from .errors import CwcliError, ErrorKind
105
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
106
106
  from .supervision import ProcessHealth
107
107
 
108
108
  OFFLINE = "offline"
@@ -250,7 +250,10 @@ def status(
250
250
  containers = get_project_containers(project_name)
251
251
  if containers is None:
252
252
  raise CwcliError(
253
- ErrorKind.DOCKER, "docker.unreachable", "Could not connect to Docker daemon."
253
+ ErrorKind.DOCKER,
254
+ "docker.unreachable",
255
+ "Could not connect to Docker daemon.",
256
+ hint=DOCKER_UNREACHABLE_HINT,
254
257
  )
255
258
  # A truly-nonexistent project (typo / never created) has NO containers with the
256
259
  # label at all - distinct from a real-but-stopped project, which has a non-empty
@@ -32,7 +32,7 @@ from docker.errors import APIError, NotFound
32
32
  from . import resolvers, supervision
33
33
  from .docker import get_project_containers
34
34
  from .envelope import Message, Result, Status
35
- from .errors import CwcliError, ErrorKind
35
+ from .errors import DOCKER_UNREACHABLE_HINT, CwcliError, ErrorKind
36
36
 
37
37
 
38
38
  @dataclass(frozen=True, slots=True, kw_only=True)
@@ -65,6 +65,7 @@ def stop(project_name: str) -> Result[StopOutcome]:
65
65
  ErrorKind.DOCKER,
66
66
  "docker.unreachable",
67
67
  "Could not connect to Docker daemon.",
68
+ hint=DOCKER_UNREACHABLE_HINT,
68
69
  )
69
70
 
70
71
  if not containers:
@@ -131,7 +132,10 @@ def stop_bench(
131
132
  containers = get_project_containers(project_name)
132
133
  if containers is None:
133
134
  raise CwcliError(
134
- ErrorKind.DOCKER, "docker.unreachable", "Could not connect to Docker daemon."
135
+ ErrorKind.DOCKER,
136
+ "docker.unreachable",
137
+ "Could not connect to Docker daemon.",
138
+ hint=DOCKER_UNREACHABLE_HINT,
135
139
  )
136
140
  if not containers:
137
141
  raise CwcliError(
@@ -1 +0,0 @@
1
- __version__ = "2.2.0"