wasm-cli 1.0.3__tar.gz → 1.2.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 (186) hide show
  1. {wasm_cli-1.0.3/src/wasm_cli.egg-info → wasm_cli-1.2.0}/PKG-INFO +1 -1
  2. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/pyproject.toml +1 -1
  3. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/setup.py +1 -1
  4. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/__init__.py +1 -1
  5. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/health.py +26 -35
  6. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/web.py +137 -125
  7. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/webapp.py +36 -27
  8. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/interactive.py +5 -3
  9. wasm_cli-1.2.0/src/wasm/core/app_state.py +205 -0
  10. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/logger.py +206 -33
  11. wasm_cli-1.2.0/src/wasm/core/net.py +252 -0
  12. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/registry.py +24 -0
  13. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/service_manager.py +8 -0
  14. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/process_monitor.py +10 -1
  15. wasm_cli-1.2.0/src/wasm/web/events.py +214 -0
  16. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/jobs.py +20 -0
  17. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/server.py +125 -39
  18. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/app.css +357 -10
  19. wasm_cli-1.2.0/src/wasm/web/static/panel.js +548 -0
  20. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/_macros.html +34 -6
  21. wasm_cli-1.2.0/src/wasm/web/templates/base.html +169 -0
  22. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/login.html +26 -11
  23. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/activity.html +7 -6
  24. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/app.html +21 -17
  25. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/backups.html +4 -1
  26. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/certificates.html +12 -5
  27. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/dashboard.html +8 -8
  28. wasm_cli-1.2.0/src/wasm/web/templates/pages/deploy.html +123 -0
  29. wasm_cli-1.2.0/src/wasm/web/templates/pages/failure.html +31 -0
  30. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/resources.html +13 -3
  31. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/settings.html +2 -2
  32. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/context.py +8 -1
  33. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/resources.py +146 -1
  34. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/router.py +301 -2
  35. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/websockets/router.py +43 -12
  36. {wasm_cli-1.0.3 → wasm_cli-1.2.0/src/wasm_cli.egg-info}/PKG-INFO +1 -1
  37. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/SOURCES.txt +5 -1
  38. wasm_cli-1.0.3/src/wasm/web/static/panel.js +0 -233
  39. wasm_cli-1.0.3/src/wasm/web/static/vendor/alpine.min.js +0 -5
  40. wasm_cli-1.0.3/src/wasm/web/templates/base.html +0 -124
  41. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/LICENSE +0 -0
  42. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/MANIFEST.in +0 -0
  43. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/README.md +0 -0
  44. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/MONITOR.md +0 -0
  45. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/OBS_SETUP.md +0 -0
  46. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/assets/logo.png +0 -0
  47. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/assets/logo_bg.png +0 -0
  48. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-panel-design-direction.md +0 -0
  49. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-v1-refactor-design.md +0 -0
  50. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/man/wasm.1 +0 -0
  51. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/setup.cfg +0 -0
  52. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/__main__.py +0 -0
  53. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/__init__.py +0 -0
  54. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/app.py +0 -0
  55. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/__init__.py +0 -0
  56. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/backup.py +0 -0
  57. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/cert.py +0 -0
  58. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/config.py +0 -0
  59. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/db.py +0 -0
  60. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/env.py +0 -0
  61. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/monitor.py +0 -0
  62. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/service.py +0 -0
  63. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/setup.py +0 -0
  64. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/site.py +0 -0
  65. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/store.py +0 -0
  66. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/commands/version.py +0 -0
  67. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/cli/prompts.py +0 -0
  68. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/completions/README.md +0 -0
  69. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/completions/_wasm +0 -0
  70. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/completions/wasm.bash +0 -0
  71. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/completions/wasm.fish +0 -0
  72. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/__init__.py +0 -0
  73. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/config.py +0 -0
  74. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/dependencies.py +0 -0
  75. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/exceptions.py +0 -0
  76. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/fs.py +0 -0
  77. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/runner.py +0 -0
  78. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/store.py +0 -0
  79. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/update_checker.py +0 -0
  80. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/core/utils.py +0 -0
  81. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/__init__.py +0 -0
  82. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/auto.py +0 -0
  83. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/base.py +0 -0
  84. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/docker_compose.py +0 -0
  85. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/__init__.py +0 -0
  86. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/env_manager.py +0 -0
  87. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/health.py +0 -0
  88. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/nginx_config.py +0 -0
  89. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/package_manager.py +0 -0
  90. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/path_resolver.py +0 -0
  91. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/preflight.py +0 -0
  92. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/prisma.py +0 -0
  93. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/registration.py +0 -0
  94. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/summary.py +0 -0
  95. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/turbo.py +0 -0
  96. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/workspace.py +0 -0
  97. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/interface.py +0 -0
  98. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/monorepo.py +0 -0
  99. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/nextjs.py +0 -0
  100. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/nodejs.py +0 -0
  101. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/pipeline.py +0 -0
  102. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/python.py +0 -0
  103. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/static.py +0 -0
  104. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/deployers/vite.py +0 -0
  105. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/main.py +0 -0
  106. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/__init__.py +0 -0
  107. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/apache_manager.py +0 -0
  108. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/backup_manager.py +0 -0
  109. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/backup_scheduler.py +0 -0
  110. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/base_manager.py +0 -0
  111. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/cert_manager.py +0 -0
  112. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/__init__.py +0 -0
  113. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/base.py +0 -0
  114. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/mongodb.py +0 -0
  115. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/mysql.py +0 -0
  116. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/postgres.py +0 -0
  117. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/redis.py +0 -0
  118. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/database/registry.py +0 -0
  119. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/nginx_manager.py +0 -0
  120. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/source_manager.py +0 -0
  121. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/managers/webserver.py +0 -0
  122. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/__init__.py +0 -0
  123. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/email_notifier.py +0 -0
  124. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/metrics.py +0 -0
  125. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/models.py +0 -0
  126. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/observation_store.py +0 -0
  127. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/monitor/signals.py +0 -0
  128. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/__init__.py +0 -0
  129. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/apache/proxy.conf.j2 +0 -0
  130. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/apache/static.conf.j2 +0 -0
  131. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/nginx/advanced.conf.j2 +0 -0
  132. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/nginx/monorepo.conf.j2 +0 -0
  133. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/nginx/proxy.conf.j2 +0 -0
  134. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/nginx/static.conf.j2 +0 -0
  135. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/systemd/_escape.j2 +0 -0
  136. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/systemd/app.service.j2 +0 -0
  137. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-service.j2 +0 -0
  138. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-timer.j2 +0 -0
  139. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/templates/systemd/docker-compose.service.j2 +0 -0
  140. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/__init__.py +0 -0
  141. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/domain.py +0 -0
  142. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/environment.py +0 -0
  143. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/names.py +0 -0
  144. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/port.py +0 -0
  145. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/source.py +0 -0
  146. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/validators/ssh.py +0 -0
  147. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/__init__.py +0 -0
  148. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/__init__.py +0 -0
  149. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/apps.py +0 -0
  150. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/auth.py +0 -0
  151. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/backups.py +0 -0
  152. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/certs.py +0 -0
  153. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/config.py +0 -0
  154. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/databases.py +0 -0
  155. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/deps.py +0 -0
  156. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/jobs.py +0 -0
  157. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/monitor.py +0 -0
  158. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/router.py +0 -0
  159. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/services.py +0 -0
  160. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/sites.py +0 -0
  161. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/api/system.py +0 -0
  162. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/auth.py +0 -0
  163. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/logo.png +0 -0
  164. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/logo.webp +0 -0
  165. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  166. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  167. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2 +0 -0
  168. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-condensed-latin-600-normal.woff2 +0 -0
  169. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  170. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  171. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  172. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/htmx.min.js +0 -0
  173. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.css +0 -0
  174. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.js +0 -0
  175. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.css +0 -0
  176. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.js +0 -0
  177. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/fragments/machine.html +0 -0
  178. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/missing.html +0 -0
  179. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/__init__.py +0 -0
  180. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/machine.py +0 -0
  181. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/views/rendering.py +0 -0
  182. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm/web/websockets/__init__.py +0 -0
  183. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/dependency_links.txt +0 -0
  184. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/entry_points.txt +0 -0
  185. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/requires.txt +0 -0
  186. {wasm_cli-1.0.3 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wasm-cli
3
- Version: 1.0.3
3
+ Version: 1.2.0
4
4
  Summary: Web App System Management - Deploy and manage web applications on Linux servers
5
5
  Author: Yago López Prado
6
6
  Author-email: Yago López Prado <yago.lopez.adeje@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wasm-cli"
7
- version = "1.0.3"
7
+ version = "1.2.0"
8
8
  description = "Web App System Management - Deploy and manage web applications on Linux servers"
9
9
  readme = "README.md"
10
10
  license = { text = "WASM-NCSAL" }
@@ -9,7 +9,7 @@ from setuptools import setup, find_packages
9
9
 
10
10
  setup(
11
11
  name="wasm-cli",
12
- version="1.0.3",
12
+ version="1.2.0",
13
13
  description="Web App System Management - Deploy and manage web applications on Linux servers",
14
14
  author="Yago López Prado",
15
15
  author_email="yago.lopez.adeje@gmail.com",
@@ -15,7 +15,7 @@ from importlib.metadata import version as _installed_version
15
15
  #: as an OBS build directory. The single source of truth is the ``version``
16
16
  #: field of pyproject.toml; ``scripts/release.py`` keeps this literal and the
17
17
  #: distribution packaging files in step with it.
18
- _FALLBACK_VERSION = "1.0.3"
18
+ _FALLBACK_VERSION = "1.2.0"
19
19
 
20
20
  try:
21
21
  __version__ = _installed_version("wasm-cli")
@@ -28,11 +28,11 @@ from datetime import datetime
28
28
  import click
29
29
 
30
30
  from wasm.cli.app import Context, pass_context
31
+ from wasm.core.app_state import RUNNING, STATIC, resolve_states
31
32
  from wasm.core.config import Config
32
- from wasm.core.exceptions import ServiceError, WASMError
33
+ from wasm.core.exceptions import WASMError
33
34
  from wasm.core.logger import Logger
34
35
  from wasm.core.store import get_store
35
- from wasm.core.utils import domain_to_app_name
36
36
  from wasm.managers.apache_manager import ApacheManager
37
37
  from wasm.managers.cert_manager import CertificateInfo, CertManager
38
38
  from wasm.managers.nginx_manager import NginxManager
@@ -50,21 +50,14 @@ def _print_status(logger: Logger, key: str, value: str, status: str) -> None:
50
50
  Print a key-value pair with status indicator.
51
51
 
52
52
  Args:
53
- logger: Logger of the current command, kept for signature stability.
53
+ logger: Logger of the current command. Writing through it is what makes
54
+ ``wasm --no-color health`` colourless; the escape codes used to be
55
+ written to stdout directly, so the flag did nothing here.
54
56
  key: Name of the checked item.
55
57
  value: Human readable result.
56
58
  status: One of "ok", "warning", "error" or "info".
57
59
  """
58
- if status == "ok":
59
- indicator = "\033[32m[OK]\033[0m"
60
- elif status == "warning":
61
- indicator = "\033[33m[!]\033[0m"
62
- elif status == "error":
63
- indicator = "\033[31m[X]\033[0m"
64
- else: # info
65
- indicator = "\033[34m[i]\033[0m"
66
-
67
- print(f" {indicator} {key}: {value}")
60
+ logger.check(key, value, status)
68
61
 
69
62
 
70
63
  def _days_until(expiry: str) -> int | None:
@@ -222,37 +215,35 @@ def run_health_check(verbose: bool = False) -> int:
222
215
  service_manager = ServiceManager(verbose=verbose)
223
216
 
224
217
  apps = store.list_apps()
225
- apps_running = 0
226
- apps_stopped = 0
227
- apps_failed = 0
228
-
229
- for app in apps:
230
- try:
231
- # The unit was created from domain_to_app_name(); a hand-rolled
232
- # replace() disagrees with it for any domain that is not lowercase.
233
- status = service_manager.get_status(domain_to_app_name(app.domain))
234
- except ServiceError as e:
235
- apps_failed += 1
236
- warnings.append(f"App '{app.domain}' could not be queried: {e}")
237
- continue
238
-
239
- if status.get("active"):
240
- apps_running += 1
241
- else:
242
- apps_stopped += 1
243
- warnings.append(f"App '{app.domain}' is not running")
218
+
219
+ # The same resolver `wasm list` uses. When these two commands each decided
220
+ # for themselves what "running" meant, list reported fifteen applications
221
+ # running while this reported seven stopped, and five of the seven were
222
+ # static sites that have no service to run in the first place.
223
+ states = resolve_states(apps, service_manager)
224
+
225
+ apps_running = sum(1 for s in states.values() if s.label == RUNNING)
226
+ apps_static = sum(1 for s in states.values() if s.label == STATIC)
227
+ unhealthy = [(domain, s) for domain, s in states.items() if not s.healthy]
228
+
229
+ for domain, current in unhealthy:
230
+ warnings.append(f"App '{domain}' - {current.detail or current.label.lower()}")
244
231
 
245
232
  total_apps = len(apps)
233
+ served = apps_running + apps_static
246
234
  if total_apps > 0:
247
- if apps_stopped > 0 or apps_failed > 0:
235
+ summary = f"{served}/{total_apps} serving"
236
+ if apps_static:
237
+ summary += f" ({apps_static} static)"
238
+ if unhealthy:
248
239
  _print_status(
249
240
  logger,
250
241
  "Applications",
251
- f"{apps_running}/{total_apps} running, {apps_stopped} stopped",
242
+ f"{summary}, {len(unhealthy)} need attention",
252
243
  "warning",
253
244
  )
254
245
  else:
255
- _print_status(logger, "Applications", f"{apps_running}/{total_apps} running", "ok")
246
+ _print_status(logger, "Applications", summary, "ok")
256
247
  else:
257
248
  _print_status(logger, "Applications", "No applications deployed", "info")
258
249
 
@@ -40,7 +40,6 @@ Two structural notes about the Click migration:
40
40
  from __future__ import annotations
41
41
 
42
42
  import importlib.util
43
- import ipaddress
44
43
  import os
45
44
  import signal
46
45
  import socket
@@ -59,6 +58,13 @@ from wasm.cli.app import Context, enable_dry_run, pass_context
59
58
  from wasm.core.exceptions import SecurityError, WASMError
60
59
  from wasm.core.fs import get_fs
61
60
  from wasm.core.logger import Logger, set_colors_disabled
61
+ from wasm.core.net import (
62
+ host_addresses,
63
+ is_loopback_host,
64
+ loopback_access_lines,
65
+ normalize_host,
66
+ strip_brackets,
67
+ )
62
68
  from wasm.core.runner import get_runner
63
69
 
64
70
  if TYPE_CHECKING:
@@ -68,23 +74,18 @@ if TYPE_CHECKING:
68
74
  PID_FILE = Path("/var/run/wasm-web.pid")
69
75
  PID_FILE_USER = Path.home() / ".wasm" / "web.pid"
70
76
 
71
- #: The address a socket is given when it should answer on every interface. It
72
- #: is named here to be recognised and refused, never bound by default.
73
- ALL_INTERFACES = "0.0.0.0" # noqa: S104
74
-
75
- #: Spellings of "every interface" that no resolver accepts, so they cannot be
76
- #: classified by looking them up. The empty string is the one that mattered: a
77
- #: version of this module kept a set of loopback *strings* with "" in it, and
78
- #: ``wasm web start --host ""`` walked past the refusal below and bound the
79
- #: root panel to every interface in cleartext.
80
- WILDCARD_SPELLINGS = frozenset({"", "*"})
81
-
82
77
  #: Package installation is slow on a cold cache but must not hang a session.
83
78
  INSTALL_TIMEOUT = 900
84
79
 
85
80
  #: Seconds between stopping and starting again, so the port is released.
86
81
  RESTART_PAUSE = 1
87
82
 
83
+ #: How far past a taken port to look for a free one to name. The panel is not
84
+ #: moved automatically - see :func:`_start` - but the port offered instead has
85
+ #: to be one that will actually work, and the search has to finish while the
86
+ #: operator is still reading the error.
87
+ PORT_SUGGESTION_SPAN = 20
88
+
88
89
  F = TypeVar("F", bound=Callable[..., Any])
89
90
 
90
91
 
@@ -208,110 +209,6 @@ def _exit(code: int) -> NoReturn:
208
209
  # How the panel is exposed
209
210
  # ---------------------------------------------------------------------------
210
211
 
211
- #: Address family alias, for the helpers below.
212
- IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
213
-
214
-
215
- def _strip_brackets(host: str) -> str:
216
- """
217
- Remove the brackets an IPv6 literal is often written with.
218
-
219
- Args:
220
- host: The spelling the operator typed.
221
-
222
- Returns:
223
- The spelling without a surrounding ``[]`` pair.
224
- """
225
- candidate = host.strip()
226
- if len(candidate) > 1 and candidate.startswith("[") and candidate.endswith("]"):
227
- return candidate[1:-1]
228
- return candidate
229
-
230
-
231
- def _host_addresses(host: str) -> tuple[IPAddress, ...]:
232
- """
233
- Return every address a host spelling would end up bound to.
234
-
235
- Comparing host strings is what made this decision wrong: "" , "*", "0",
236
- "0.0.0.0", "::" and a name in ``/etc/hosts`` pointing at 0.0.0.0 are six
237
- spellings of the same socket, and a set of known-good strings misses five
238
- of them. The resolver is asked instead, and its answer is classified with
239
- :mod:`ipaddress`.
240
-
241
- Args:
242
- host: The spelling the operator typed.
243
-
244
- Returns:
245
- The addresses, empty when the spelling cannot be resolved. Empty means
246
- "unknown", and every caller treats unknown as exposed.
247
- """
248
- candidate = _strip_brackets(host)
249
- if candidate in WILDCARD_SPELLINGS:
250
- return (ipaddress.ip_address(ALL_INTERFACES),)
251
-
252
- try:
253
- return (ipaddress.ip_address(candidate),)
254
- except ValueError:
255
- pass
256
-
257
- try:
258
- # A resolution covers names and also the inet_aton spellings ipaddress
259
- # refuses but a socket accepts, such as "0" (INADDR_ANY) and "127.1".
260
- infos = socket.getaddrinfo(candidate, None, type=socket.SOCK_STREAM)
261
- except (OSError, UnicodeError, ValueError):
262
- return ()
263
-
264
- addresses = []
265
- for info in infos:
266
- # A link-local sockaddr carries a %scope suffix that ipaddress refuses.
267
- text = str(info[4][0]).split("%", 1)[0]
268
- try:
269
- addresses.append(ipaddress.ip_address(text))
270
- except ValueError:
271
- return ()
272
- return tuple(addresses)
273
-
274
-
275
- def _is_loopback_host(host: str) -> bool:
276
- """
277
- Report whether only this machine could reach a panel bound to a host.
278
-
279
- Args:
280
- host: The spelling the operator typed.
281
-
282
- Returns:
283
- True only when the spelling is known to resolve to loopback addresses
284
- and nothing else. A spelling that cannot be resolved is not loopback,
285
- because guessing in the other direction publishes a root shell.
286
- """
287
- addresses = _host_addresses(host)
288
- return bool(addresses) and all(address.is_loopback for address in addresses)
289
-
290
-
291
- def _normalize_host(host: str) -> str:
292
- """
293
- Canonicalise a host spelling so what is checked is what is reported.
294
-
295
- Args:
296
- host: The spelling the operator typed.
297
-
298
- Returns:
299
- The canonical spelling. Every way of writing "every interface" becomes
300
- the address it binds, so the refusal below names a real address instead
301
- of quoting an empty string back at the operator.
302
- """
303
- candidate = _strip_brackets(host)
304
- addresses = _host_addresses(candidate)
305
- if not addresses:
306
- return candidate
307
- if candidate in WILDCARD_SPELLINGS or all(address.is_unspecified for address in addresses):
308
- return str(addresses[0])
309
- try:
310
- return str(ipaddress.ip_address(candidate))
311
- except ValueError:
312
- # A name: binding it is the resolver's business, not this module's.
313
- return candidate
314
-
315
212
 
316
213
  @dataclass(frozen=True)
317
214
  class StartOptions:
@@ -422,11 +319,11 @@ def _build_security_config(options: StartOptions) -> SecurityConfig:
422
319
 
423
320
  # Normalised first, so the exposure decision, the message and the address
424
321
  # that is finally bound all talk about the same thing.
425
- host = _normalize_host(options.host)
322
+ host = normalize_host(options.host)
426
323
  port = options.port
427
324
  whitelist = list(options.allow_ip)
428
325
  proxies = list(options.trusted_proxy)
429
- local_only = _is_loopback_host(host)
326
+ local_only = is_loopback_host(host)
430
327
 
431
328
  if options.require_https and not (options.tls_cert and options.tls_key):
432
329
  raise SecurityError(
@@ -443,7 +340,7 @@ def _build_security_config(options: StartOptions) -> SecurityConfig:
443
340
  unresolved = (
444
341
  f"WASM could not resolve {host!r}, so it cannot show that only this machine "
445
342
  "would reach the panel, and treats it as exposed.\n"
446
- if not _host_addresses(host)
343
+ if not host_addresses(host)
447
344
  else ""
448
345
  )
449
346
  raise SecurityError(
@@ -708,6 +605,116 @@ def _prompt_install(missing_apt: list[str], missing_pip: list[str], verbose: boo
708
605
  # ---------------------------------------------------------------------------
709
606
 
710
607
 
608
+ def _port_in_use(host: str, port: int) -> bool:
609
+ """
610
+ Ask whether the panel's address is already taken.
611
+
612
+ Binding is the only reliable answer: a connection attempt says nothing
613
+ about a socket bound with no backlog, and reading ``ss`` output means
614
+ parsing a different format on every distribution.
615
+
616
+ Args:
617
+ host: Address the panel would bind.
618
+ port: Port the panel would bind.
619
+
620
+ Returns:
621
+ True when binding would fail because something holds the address.
622
+ """
623
+ family = socket.AF_INET6 if ":" in strip_brackets(host) else socket.AF_INET
624
+ probe = socket.socket(family, socket.SOCK_STREAM)
625
+ try:
626
+ # Deliberately not SO_REUSEADDR: with it the bind succeeds against a
627
+ # socket in TIME_WAIT, which is the case this is meant to catch.
628
+ probe.bind((strip_brackets(host), port))
629
+ except OSError:
630
+ return True
631
+ except (ValueError, socket.gaierror):
632
+ # An address this process cannot even parse is not this check's problem;
633
+ # the bind that follows will report it properly.
634
+ return False
635
+ finally:
636
+ probe.close()
637
+ return False
638
+
639
+
640
+ def _first_free_port(host: str, port: int) -> int | None:
641
+ """
642
+ Find a port to offer in place of one that is taken.
643
+
644
+ Args:
645
+ host: Address the panel would bind.
646
+ port: The port that turned out to be taken.
647
+
648
+ Returns:
649
+ The first free port above it, or None when the whole search span is
650
+ held. Naming a port without checking it is what made the old suggestion
651
+ useless: it was the requested port plus one, and on a machine busy
652
+ enough to hit this at all that is frequently taken too.
653
+ """
654
+ for candidate in range(port + 1, min(port + 1 + PORT_SUGGESTION_SPAN, 65536)):
655
+ if not _port_in_use(host, candidate):
656
+ return candidate
657
+ return None
658
+
659
+
660
+ def _report_taken_port(host: str, port: int, logger: Logger) -> None:
661
+ """
662
+ Explain a taken port, including why the panel does not move itself.
663
+
664
+ Moving to the next free port would be the obliging thing to do and it is
665
+ the wrong thing to do. The port is an address other things point at - an
666
+ SSH tunnel, a reverse proxy, a bookmark, a script - so a panel that lands
667
+ somewhere new whenever the old place is busy cannot be automated. It would
668
+ also paper over the case this check exists to catch: a second panel, still
669
+ running, still holding a root session.
670
+
671
+ None of that is obvious from the outside, so it is said rather than
672
+ implied.
673
+
674
+ Args:
675
+ host: Address the panel would have bound.
676
+ port: The port something else holds.
677
+ logger: Logger for the report.
678
+ """
679
+ logger.error(f"Something is already listening on {host}:{port}")
680
+ logger.info("WASM does not move the panel to another port on its own:")
681
+ logger.info(" the port is what your SSH tunnel, proxy and bookmarks point at,")
682
+ logger.info(" and moving it would hide a panel that is still running here.")
683
+ logger.info("If it is a panel this machine has forgotten about:")
684
+ logger.info(" wasm web stop")
685
+ logger.info("To see what holds the port:")
686
+ logger.info(f" ss -ltnp 'sport = :{port}'")
687
+
688
+ free = _first_free_port(host, port)
689
+ if free is None:
690
+ logger.info(
691
+ f"Nothing up to {port + PORT_SUGGESTION_SPAN} is free either. "
692
+ "Choose a port with --port."
693
+ )
694
+ return
695
+ logger.info(f"Or serve on {free}, which is free right now:")
696
+ logger.info(f" wasm web start --port {free}")
697
+
698
+
699
+ def _report_daemon_started(config: SecurityConfig, pid: int, logger: Logger) -> None:
700
+ """
701
+ Report a backgrounded panel, and how to reach it.
702
+
703
+ Args:
704
+ config: The configuration it was started with.
705
+ pid: Process id of the panel.
706
+ logger: Logger for the report.
707
+ """
708
+ scheme = "https" if config.require_https else "http"
709
+
710
+ logger.success(f"Web server started in background (PID: {pid})")
711
+ logger.info(f"Server running at {scheme}://{config.host}:{config.port}")
712
+ for line in loopback_access_lines(config.host, config.port, scheme=scheme):
713
+ logger.info(line)
714
+ logger.info("Use 'wasm web status' to check status")
715
+ logger.info("Use 'wasm web stop' to stop the server")
716
+
717
+
711
718
  def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> int:
712
719
  """
713
720
  Start the panel, refusing an unsafe exposure first.
@@ -773,11 +780,20 @@ def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> in
773
780
  # Process not running, remove stale PID file
774
781
  get_fs().remove(pid_file, missing_ok=True)
775
782
 
783
+ # The PID file only catches a panel this machine still has a record of. A
784
+ # panel whose PID file was removed, or anything else on the port, walked
785
+ # past that check: the token was printed and then uvicorn failed with a
786
+ # bare OSError, leaving an operator holding a credential for a server that
787
+ # never started.
788
+ if not dry_run and _port_in_use(host, config.port):
789
+ _report_taken_port(host, config.port, logger)
790
+ return 1
791
+
776
792
  if config.ip_whitelist:
777
793
  logger.info(f"Only these clients may connect: {', '.join(config.ip_whitelist)}")
778
794
  if config.trusted_proxies:
779
795
  logger.info(f"Forwarding headers believed from: {', '.join(config.trusted_proxies)}")
780
- elif _is_loopback_host(host) and not config.require_https:
796
+ elif is_loopback_host(host) and not config.require_https:
781
797
  logger.warning(
782
798
  "Serving plain HTTP on loopback. Behind a TLS proxy, pass --trusted-proxy "
783
799
  "so the session cookie is issued with the Secure flag."
@@ -835,17 +851,13 @@ def _start_daemon(config: SecurityConfig, verbose: bool) -> int:
835
851
  Exit code.
836
852
  """
837
853
  logger = Logger(verbose=verbose)
838
- scheme = "https" if config.require_https else "http"
839
854
 
840
855
  # Fork process
841
856
  pid = os.fork()
842
857
 
843
858
  if pid > 0:
844
859
  # Parent process
845
- logger.success(f"Web server started in background (PID: {pid})")
846
- logger.info(f"Server running at {scheme}://{config.host}:{config.port}")
847
- logger.info("Use 'wasm web status' to check status")
848
- logger.info("Use 'wasm web stop' to stop the server")
860
+ _report_daemon_started(config, pid, logger)
849
861
  return 0
850
862
 
851
863
  # Child process
@@ -33,19 +33,21 @@ from typing import Any, TypeVar
33
33
  import click
34
34
 
35
35
  from wasm.cli.app import Context, enable_dry_run, pass_context
36
+ from wasm.core.app_state import RUNNING, STATIC, resolve_states
36
37
  from wasm.core.config import Config
37
38
  from wasm.core.dependencies import check_deployment_ready
38
39
  from wasm.core.exceptions import DeploymentError, ServiceError, WASMError
39
- from wasm.core.logger import Logger, set_colors_disabled
40
+ from wasm.core.logger import Logger, set_colors_disabled, state, styled
40
41
  from wasm.core.runner import (
41
42
  CommandResult,
42
43
  get_runner,
43
44
  )
44
- from wasm.core.store import AppStatus, get_store
45
+ from wasm.core.store import get_store
45
46
  from wasm.core.utils import domain_to_app_name, remove_directory
46
47
  from wasm.deployers import detect_app_type, get_deployer
47
48
  from wasm.deployers.docker_compose import DockerComposeDeployer
48
49
  from wasm.deployers.monorepo import MonorepoDeployer
50
+ from wasm.deployers.registry import available_types
49
51
  from wasm.managers.apache_manager import ApacheManager
50
52
  from wasm.managers.backup_manager import RollbackManager
51
53
  from wasm.managers.cert_manager import CertManager
@@ -67,17 +69,10 @@ _FOLLOW_TIMEOUT = 86400
67
69
  #: Docker Compose pulls images and rebuilds; it needs room.
68
70
  _COMPOSE_TIMEOUT = 1800
69
71
 
70
- #: Application types ``create`` accepts, in the order they are offered.
71
- APP_TYPES = [
72
- "nextjs",
73
- "nodejs",
74
- "vite",
75
- "python",
76
- "static",
77
- "monorepo",
78
- "docker-compose",
79
- "auto",
80
- ]
72
+ #: Application types ``create`` accepts, in the order they are offered. Read
73
+ #: from the registry rather than typed out here: a hand-written copy is a copy
74
+ #: that goes stale the first time a deployer is added, and it did.
75
+ APP_TYPES = [entry["type"] for entry in available_types()]
81
76
 
82
77
  #: Node package managers ``create`` and ``update`` accept.
83
78
  PACKAGE_MANAGERS = ["npm", "pnpm", "bun", "auto"]
@@ -464,30 +459,44 @@ def _list_apps(logger: Logger) -> int:
464
459
  logger.info(" wasm deploy -d example.com -s https://github.com/user/repo")
465
460
  return 0
466
461
 
462
+ # Asked of systemd and of the port, not read from the status column. That
463
+ # column is written at deploy time and never again, so it called everything
464
+ # Running while health, which did ask, reported half of them stopped.
465
+ states = resolve_states(apps, ServiceManager(verbose=logger.verbose))
466
+
467
467
  headers = ["Domain", "Type", "Status", "Port", "SSL"]
468
468
  rows = []
469
469
 
470
- labels = {
471
- AppStatus.RUNNING.value: "Running",
472
- AppStatus.STOPPED.value: "Stopped",
473
- AppStatus.DEPLOYING.value: "Deploying",
474
- AppStatus.FAILED.value: "Failed",
475
- }
476
-
477
470
  for app in apps:
478
- status_str = labels.get(app.status, "Unknown")
479
- port_str = str(app.port) if app.port else "static"
480
- ssl_str = "yes" if app.ssl_enabled else "no"
471
+ current = states[app.domain]
472
+ rows.append(
473
+ [
474
+ styled(app.domain, "bold"),
475
+ app.app_type,
476
+ state(current.label),
477
+ styled(app.port, "") if app.port else state("static"),
478
+ state("yes") if app.ssl_enabled else state("no"),
479
+ ]
480
+ )
481
481
 
482
- rows.append([app.domain, app.app_type, status_str, port_str, ssl_str])
482
+ logger.table(headers, rows, justify=["left", "left", "left", "right", "left"])
483
483
 
484
- logger.table(headers, rows)
484
+ running = sum(1 for s in states.values() if s.label == RUNNING)
485
+ static = sum(1 for s in states.values() if s.label == STATIC)
486
+ unhealthy = [(domain, s) for domain, s in states.items() if not s.healthy]
485
487
 
486
488
  logger.blank()
487
- running = sum(1 for a in apps if a.status == AppStatus.RUNNING.value)
488
- static = sum(1 for a in apps if a.is_static)
489
489
  logger.info(f"Total: {len(apps)} apps ({running} running, {static} static)")
490
490
 
491
+ # The reason belongs next to the list. Making the operator run a second
492
+ # command to find out why something says Stopped is how the contradiction
493
+ # between these two commands went unnoticed for as long as it did.
494
+ if unhealthy:
495
+ logger.blank()
496
+ logger.warning(f"{len(unhealthy)} need attention:")
497
+ for domain, current in unhealthy:
498
+ logger.list_item(f"{domain} - {current.detail or current.label.lower()}")
499
+
491
500
  return 0
492
501
 
493
502
 
@@ -34,9 +34,11 @@ class InteractiveMode:
34
34
  raise WASMError(
35
35
  "Interactive mode needs questionary, which is missing",
36
36
  details=(
37
- "It is a hard dependency, so this means a broken install.\n"
38
- " pip install --force-reinstall wasm-cli\n"
39
- " or, on a distribution package: apt install python3-questionary"
37
+ "Every other command works without it. To get it:\n"
38
+ " apt install python3-questionary Debian 13, Ubuntu 24.04+\n"
39
+ " dnf install python3-questionary Fedora 42+\n"
40
+ " pip install questionary anywhere else\n"
41
+ "It is not packaged for Debian 12, Ubuntu 22.04 or Leap 15."
40
42
  ),
41
43
  )
42
44