wasm-cli 1.1.0__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.1.0/src/wasm_cli.egg-info → wasm_cli-1.2.0}/PKG-INFO +1 -1
  2. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/pyproject.toml +1 -1
  3. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/setup.py +1 -1
  4. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/__init__.py +1 -1
  5. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/web.py +99 -134
  6. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/webapp.py +5 -11
  7. wasm_cli-1.2.0/src/wasm/core/net.py +252 -0
  8. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/registry.py +24 -0
  9. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/process_monitor.py +10 -1
  10. wasm_cli-1.2.0/src/wasm/web/events.py +214 -0
  11. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/jobs.py +20 -0
  12. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/server.py +125 -39
  13. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/app.css +357 -10
  14. wasm_cli-1.2.0/src/wasm/web/static/panel.js +548 -0
  15. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/_macros.html +34 -6
  16. wasm_cli-1.2.0/src/wasm/web/templates/base.html +169 -0
  17. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/login.html +26 -11
  18. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/activity.html +7 -6
  19. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/app.html +21 -17
  20. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/backups.html +4 -1
  21. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/certificates.html +12 -5
  22. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/dashboard.html +8 -8
  23. wasm_cli-1.2.0/src/wasm/web/templates/pages/deploy.html +123 -0
  24. wasm_cli-1.2.0/src/wasm/web/templates/pages/failure.html +31 -0
  25. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/resources.html +13 -3
  26. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/settings.html +2 -2
  27. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/context.py +8 -1
  28. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/resources.py +146 -1
  29. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/router.py +301 -2
  30. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/websockets/router.py +43 -12
  31. {wasm_cli-1.1.0 → wasm_cli-1.2.0/src/wasm_cli.egg-info}/PKG-INFO +1 -1
  32. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/SOURCES.txt +4 -1
  33. wasm_cli-1.1.0/src/wasm/web/static/panel.js +0 -233
  34. wasm_cli-1.1.0/src/wasm/web/static/vendor/alpine.min.js +0 -5
  35. wasm_cli-1.1.0/src/wasm/web/templates/base.html +0 -124
  36. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/LICENSE +0 -0
  37. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/MANIFEST.in +0 -0
  38. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/README.md +0 -0
  39. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/MONITOR.md +0 -0
  40. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/OBS_SETUP.md +0 -0
  41. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/assets/logo.png +0 -0
  42. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/assets/logo_bg.png +0 -0
  43. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-panel-design-direction.md +0 -0
  44. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-v1-refactor-design.md +0 -0
  45. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/man/wasm.1 +0 -0
  46. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/setup.cfg +0 -0
  47. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/__main__.py +0 -0
  48. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/__init__.py +0 -0
  49. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/app.py +0 -0
  50. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/__init__.py +0 -0
  51. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/backup.py +0 -0
  52. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/cert.py +0 -0
  53. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/config.py +0 -0
  54. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/db.py +0 -0
  55. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/env.py +0 -0
  56. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/health.py +0 -0
  57. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/monitor.py +0 -0
  58. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/service.py +0 -0
  59. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/setup.py +0 -0
  60. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/site.py +0 -0
  61. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/store.py +0 -0
  62. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/version.py +0 -0
  63. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/interactive.py +0 -0
  64. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/prompts.py +0 -0
  65. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/README.md +0 -0
  66. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/_wasm +0 -0
  67. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/wasm.bash +0 -0
  68. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/wasm.fish +0 -0
  69. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/__init__.py +0 -0
  70. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/app_state.py +0 -0
  71. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/config.py +0 -0
  72. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/dependencies.py +0 -0
  73. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/exceptions.py +0 -0
  74. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/fs.py +0 -0
  75. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/logger.py +0 -0
  76. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/runner.py +0 -0
  77. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/store.py +0 -0
  78. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/update_checker.py +0 -0
  79. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/utils.py +0 -0
  80. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/__init__.py +0 -0
  81. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/auto.py +0 -0
  82. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/base.py +0 -0
  83. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/docker_compose.py +0 -0
  84. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/__init__.py +0 -0
  85. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/env_manager.py +0 -0
  86. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/health.py +0 -0
  87. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/nginx_config.py +0 -0
  88. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/package_manager.py +0 -0
  89. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/path_resolver.py +0 -0
  90. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/preflight.py +0 -0
  91. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/prisma.py +0 -0
  92. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/registration.py +0 -0
  93. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/summary.py +0 -0
  94. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/turbo.py +0 -0
  95. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/workspace.py +0 -0
  96. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/interface.py +0 -0
  97. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/monorepo.py +0 -0
  98. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/nextjs.py +0 -0
  99. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/nodejs.py +0 -0
  100. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/pipeline.py +0 -0
  101. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/python.py +0 -0
  102. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/static.py +0 -0
  103. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/vite.py +0 -0
  104. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/main.py +0 -0
  105. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/__init__.py +0 -0
  106. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/apache_manager.py +0 -0
  107. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/backup_manager.py +0 -0
  108. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/backup_scheduler.py +0 -0
  109. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/base_manager.py +0 -0
  110. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/cert_manager.py +0 -0
  111. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/__init__.py +0 -0
  112. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/base.py +0 -0
  113. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/mongodb.py +0 -0
  114. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/mysql.py +0 -0
  115. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/postgres.py +0 -0
  116. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/redis.py +0 -0
  117. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/registry.py +0 -0
  118. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/nginx_manager.py +0 -0
  119. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/service_manager.py +0 -0
  120. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/source_manager.py +0 -0
  121. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/webserver.py +0 -0
  122. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/__init__.py +0 -0
  123. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/email_notifier.py +0 -0
  124. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/metrics.py +0 -0
  125. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/models.py +0 -0
  126. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/observation_store.py +0 -0
  127. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/signals.py +0 -0
  128. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/__init__.py +0 -0
  129. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/apache/proxy.conf.j2 +0 -0
  130. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/apache/static.conf.j2 +0 -0
  131. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/advanced.conf.j2 +0 -0
  132. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/monorepo.conf.j2 +0 -0
  133. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/proxy.conf.j2 +0 -0
  134. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/static.conf.j2 +0 -0
  135. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/_escape.j2 +0 -0
  136. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/app.service.j2 +0 -0
  137. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-service.j2 +0 -0
  138. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-timer.j2 +0 -0
  139. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/docker-compose.service.j2 +0 -0
  140. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/__init__.py +0 -0
  141. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/domain.py +0 -0
  142. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/environment.py +0 -0
  143. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/names.py +0 -0
  144. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/port.py +0 -0
  145. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/source.py +0 -0
  146. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/ssh.py +0 -0
  147. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/__init__.py +0 -0
  148. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/__init__.py +0 -0
  149. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/apps.py +0 -0
  150. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/auth.py +0 -0
  151. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/backups.py +0 -0
  152. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/certs.py +0 -0
  153. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/config.py +0 -0
  154. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/databases.py +0 -0
  155. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/deps.py +0 -0
  156. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/jobs.py +0 -0
  157. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/monitor.py +0 -0
  158. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/router.py +0 -0
  159. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/services.py +0 -0
  160. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/sites.py +0 -0
  161. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/system.py +0 -0
  162. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/auth.py +0 -0
  163. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/logo.png +0 -0
  164. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/logo.webp +0 -0
  165. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  166. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  167. {wasm_cli-1.1.0 → 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.1.0 → 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.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  170. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  171. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  172. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/htmx.min.js +0 -0
  173. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.css +0 -0
  174. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.js +0 -0
  175. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.css +0 -0
  176. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.js +0 -0
  177. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/fragments/machine.html +0 -0
  178. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/missing.html +0 -0
  179. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/__init__.py +0 -0
  180. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/machine.py +0 -0
  181. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/rendering.py +0 -0
  182. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/websockets/__init__.py +0 -0
  183. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/dependency_links.txt +0 -0
  184. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/entry_points.txt +0 -0
  185. {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/requires.txt +0 -0
  186. {wasm_cli-1.1.0 → 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.1.0
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.1.0"
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.1.0",
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.1.0"
18
+ _FALLBACK_VERSION = "1.2.0"
19
19
 
20
20
  try:
21
21
  __version__ = _installed_version("wasm-cli")
@@ -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(
@@ -723,12 +620,12 @@ def _port_in_use(host: str, port: int) -> bool:
723
620
  Returns:
724
621
  True when binding would fail because something holds the address.
725
622
  """
726
- family = socket.AF_INET6 if ":" in _strip_brackets(host) else socket.AF_INET
623
+ family = socket.AF_INET6 if ":" in strip_brackets(host) else socket.AF_INET
727
624
  probe = socket.socket(family, socket.SOCK_STREAM)
728
625
  try:
729
626
  # Deliberately not SO_REUSEADDR: with it the bind succeeds against a
730
627
  # socket in TIME_WAIT, which is the case this is meant to catch.
731
- probe.bind((_strip_brackets(host), port))
628
+ probe.bind((strip_brackets(host), port))
732
629
  except OSError:
733
630
  return True
734
631
  except (ValueError, socket.gaierror):
@@ -740,6 +637,84 @@ def _port_in_use(host: str, port: int) -> bool:
740
637
  return False
741
638
 
742
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
+
743
718
  def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> int:
744
719
  """
745
720
  Start the panel, refusing an unsafe exposure first.
@@ -811,20 +786,14 @@ def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> in
811
786
  # bare OSError, leaving an operator holding a credential for a server that
812
787
  # never started.
813
788
  if not dry_run and _port_in_use(host, config.port):
814
- logger.error(f"Something is already listening on {host}:{config.port}")
815
- logger.info("If it is a panel this machine has forgotten about:")
816
- logger.info(" wasm web stop")
817
- logger.info("To see what holds the port:")
818
- logger.info(f" ss -ltnp 'sport = :{config.port}'")
819
- logger.info("Or serve somewhere else:")
820
- logger.info(f" wasm web start --port {config.port + 1}")
789
+ _report_taken_port(host, config.port, logger)
821
790
  return 1
822
791
 
823
792
  if config.ip_whitelist:
824
793
  logger.info(f"Only these clients may connect: {', '.join(config.ip_whitelist)}")
825
794
  if config.trusted_proxies:
826
795
  logger.info(f"Forwarding headers believed from: {', '.join(config.trusted_proxies)}")
827
- elif _is_loopback_host(host) and not config.require_https:
796
+ elif is_loopback_host(host) and not config.require_https:
828
797
  logger.warning(
829
798
  "Serving plain HTTP on loopback. Behind a TLS proxy, pass --trusted-proxy "
830
799
  "so the session cookie is issued with the Secure flag."
@@ -882,17 +851,13 @@ def _start_daemon(config: SecurityConfig, verbose: bool) -> int:
882
851
  Exit code.
883
852
  """
884
853
  logger = Logger(verbose=verbose)
885
- scheme = "https" if config.require_https else "http"
886
854
 
887
855
  # Fork process
888
856
  pid = os.fork()
889
857
 
890
858
  if pid > 0:
891
859
  # Parent process
892
- logger.success(f"Web server started in background (PID: {pid})")
893
- logger.info(f"Server running at {scheme}://{config.host}:{config.port}")
894
- logger.info("Use 'wasm web status' to check status")
895
- logger.info("Use 'wasm web stop' to stop the server")
860
+ _report_daemon_started(config, pid, logger)
896
861
  return 0
897
862
 
898
863
  # Child process
@@ -47,6 +47,7 @@ from wasm.core.utils import domain_to_app_name, remove_directory
47
47
  from wasm.deployers import detect_app_type, get_deployer
48
48
  from wasm.deployers.docker_compose import DockerComposeDeployer
49
49
  from wasm.deployers.monorepo import MonorepoDeployer
50
+ from wasm.deployers.registry import available_types
50
51
  from wasm.managers.apache_manager import ApacheManager
51
52
  from wasm.managers.backup_manager import RollbackManager
52
53
  from wasm.managers.cert_manager import CertManager
@@ -68,17 +69,10 @@ _FOLLOW_TIMEOUT = 86400
68
69
  #: Docker Compose pulls images and rebuilds; it needs room.
69
70
  _COMPOSE_TIMEOUT = 1800
70
71
 
71
- #: Application types ``create`` accepts, in the order they are offered.
72
- APP_TYPES = [
73
- "nextjs",
74
- "nodejs",
75
- "vite",
76
- "python",
77
- "static",
78
- "monorepo",
79
- "docker-compose",
80
- "auto",
81
- ]
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()]
82
76
 
83
77
  #: Node package managers ``create`` and ``update`` accept.
84
78
  PACKAGE_MANAGERS = ["npm", "pnpm", "bun", "auto"]
@@ -0,0 +1,252 @@
1
+ # Copyright (c) 2024-2026 Yago Lopez Prado
2
+ # Licensed under WASM-NCSAL 1.0 (Commercial use prohibited)
3
+ # https://github.com/Perkybeet/wasm/blob/main/LICENSE
4
+
5
+ """
6
+ How a listening address is classified, and how an operator reaches it.
7
+
8
+ One question is asked of this module twice. The CLI asks whether an address is
9
+ reachable from another machine, because that decides whether it will serve a
10
+ root panel on it at all. The startup banner asks the same thing, because an
11
+ address only this machine can open has to come with instructions for opening it
12
+ from somewhere else. Two callers, one classification: a security decision that
13
+ is made in two places is a security decision that drifts, and this one is
14
+ subtle enough that the second copy would have been the wrong one - "", ``*``,
15
+ ``0``, ``0.0.0.0``, ``::`` and a name resolving to any of them are six
16
+ spellings of the same socket.
17
+
18
+ The instructions matter as much as the classification. A panel started over SSH
19
+ on a server with no desktop printed ``Server: http://127.0.0.1:8080`` and
20
+ stopped there, which is an address that, from the machine the operator is
21
+ actually sitting at, does not exist.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import getpass
27
+ import ipaddress
28
+ import os
29
+ import socket
30
+
31
+ #: The address a socket is given when it should answer on every interface. It
32
+ #: is named here to be recognised and refused, never bound by default.
33
+ ALL_INTERFACES = "0.0.0.0" # noqa: S104
34
+
35
+ #: Spellings of "every interface" that no resolver accepts, so they cannot be
36
+ #: classified by looking them up. The empty string is the one that mattered: a
37
+ #: version of this check kept a set of loopback *strings* with "" in it, and
38
+ #: ``wasm web start --host ""`` walked past the refusal and bound the root
39
+ #: panel to every interface in cleartext.
40
+ WILDCARD_SPELLINGS = frozenset({"", "*"})
41
+
42
+ #: Shown in place of a login name when the passwd database has no entry for
43
+ #: this uid, which happens in containers. The command stays copy-editable.
44
+ UNKNOWN_USER = "user"
45
+
46
+ #: Address family alias, for the helpers below.
47
+ IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
48
+
49
+
50
+ def strip_brackets(host: str) -> str:
51
+ """
52
+ Remove the brackets an IPv6 literal is often written with.
53
+
54
+ Args:
55
+ host: The spelling the operator typed.
56
+
57
+ Returns:
58
+ The spelling without a surrounding ``[]`` pair.
59
+ """
60
+ candidate = host.strip()
61
+ if len(candidate) > 1 and candidate.startswith("[") and candidate.endswith("]"):
62
+ return candidate[1:-1]
63
+ return candidate
64
+
65
+
66
+ def host_addresses(host: str) -> tuple[IPAddress, ...]:
67
+ """
68
+ Return every address a host spelling would end up bound to.
69
+
70
+ Comparing host strings is what made this decision wrong: "", ``*``, ``0``,
71
+ ``0.0.0.0``, ``::`` and a name in ``/etc/hosts`` pointing at 0.0.0.0 are six
72
+ spellings of the same socket, and a set of known-good strings misses five of
73
+ them. The resolver is asked instead, and its answer is classified with
74
+ :mod:`ipaddress`.
75
+
76
+ Args:
77
+ host: The spelling the operator typed.
78
+
79
+ Returns:
80
+ The addresses, empty when the spelling cannot be resolved. Empty means
81
+ "unknown", and every caller treats unknown as exposed.
82
+ """
83
+ candidate = strip_brackets(host)
84
+ if candidate in WILDCARD_SPELLINGS:
85
+ return (ipaddress.ip_address(ALL_INTERFACES),)
86
+
87
+ try:
88
+ return (ipaddress.ip_address(candidate),)
89
+ except ValueError:
90
+ pass
91
+
92
+ try:
93
+ # A resolution covers names and also the inet_aton spellings ipaddress
94
+ # refuses but a socket accepts, such as "0" (INADDR_ANY) and "127.1".
95
+ infos = socket.getaddrinfo(candidate, None, type=socket.SOCK_STREAM)
96
+ except (OSError, UnicodeError, ValueError):
97
+ return ()
98
+
99
+ addresses = []
100
+ for info in infos:
101
+ # A link-local sockaddr carries a %scope suffix that ipaddress refuses.
102
+ text = str(info[4][0]).split("%", 1)[0]
103
+ try:
104
+ addresses.append(ipaddress.ip_address(text))
105
+ except ValueError:
106
+ return ()
107
+ return tuple(addresses)
108
+
109
+
110
+ def is_loopback_host(host: str) -> bool:
111
+ """
112
+ Report whether only this machine could reach a service bound to a host.
113
+
114
+ Args:
115
+ host: The spelling the operator typed.
116
+
117
+ Returns:
118
+ True only when the spelling is known to resolve to loopback addresses
119
+ and nothing else. A spelling that cannot be resolved is not loopback,
120
+ because guessing in the other direction publishes a root shell.
121
+ """
122
+ addresses = host_addresses(host)
123
+ return bool(addresses) and all(address.is_loopback for address in addresses)
124
+
125
+
126
+ def normalize_host(host: str) -> str:
127
+ """
128
+ Canonicalise a host spelling so what is checked is what is reported.
129
+
130
+ Args:
131
+ host: The spelling the operator typed.
132
+
133
+ Returns:
134
+ The canonical spelling. Every way of writing "every interface" becomes
135
+ the address it binds, so a refusal names a real address instead of
136
+ quoting an empty string back at the operator.
137
+ """
138
+ candidate = strip_brackets(host)
139
+ addresses = host_addresses(candidate)
140
+ if not addresses:
141
+ return candidate
142
+ if candidate in WILDCARD_SPELLINGS or all(address.is_unspecified for address in addresses):
143
+ return str(addresses[0])
144
+ try:
145
+ return str(ipaddress.ip_address(candidate))
146
+ except ValueError:
147
+ # A name: binding it is the resolver's business, not this module's.
148
+ return candidate
149
+
150
+
151
+ def local_address() -> str:
152
+ """
153
+ Best-effort address of this machine, for a banner.
154
+
155
+ Returns:
156
+ The outbound interface address, or 127.0.0.1 when it cannot be found.
157
+ """
158
+ try:
159
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
160
+ # A UDP connect sends nothing; it only asks the routing table which
161
+ # source address a packet to that destination would leave from.
162
+ sock.connect(("8.8.8.8", 80))
163
+ return str(sock.getsockname()[0])
164
+ except OSError:
165
+ return "127.0.0.1"
166
+
167
+
168
+ def server_address() -> str:
169
+ """
170
+ Name this server the way the operator's own machine would reach it.
171
+
172
+ ``SSH_CONNECTION`` is preferred over the outbound interface because it holds
173
+ the address this very session arrived on. Behind NAT, on a host with several
174
+ interfaces, or on a VPS with a separate private network, that is the address
175
+ that works and the outbound one is a guess.
176
+
177
+ Returns:
178
+ An address or a placeholder; never raises, because a banner is not worth
179
+ an exception.
180
+ """
181
+ parts = os.environ.get("SSH_CONNECTION", "").split()
182
+ if len(parts) >= 4:
183
+ return parts[2]
184
+ return local_address()
185
+
186
+
187
+ def _current_user() -> str:
188
+ """
189
+ Return the login name to put in an SSH command.
190
+
191
+ Returns:
192
+ The current user, or a placeholder when there is no passwd entry.
193
+ """
194
+ try:
195
+ return getpass.getuser()
196
+ except (KeyError, OSError):
197
+ return UNKNOWN_USER
198
+
199
+
200
+ def ssh_target() -> str:
201
+ """
202
+ Build the ``user@host`` an operator would SSH to.
203
+
204
+ Returns:
205
+ The target, ready to paste into an ssh command line.
206
+ """
207
+ return f"{_current_user()}@{server_address()}"
208
+
209
+
210
+ def loopback_access_lines(host: str, port: int, scheme: str = "http") -> tuple[str, ...]:
211
+ """
212
+ Explain how to open a service that only answers on this machine.
213
+
214
+ Args:
215
+ host: The address the service is bound to.
216
+ port: The port it listens on.
217
+ scheme: ``http`` or ``https``, matching what it serves.
218
+
219
+ Returns:
220
+ Lines for a banner, empty when the address is reachable from another
221
+ machine and needs no forwarding. Callers print them as they are.
222
+ """
223
+ if not is_loopback_host(host):
224
+ return ()
225
+
226
+ literal = normalize_host(host)
227
+ # ssh -L splits its argument on colons, so an IPv6 literal has to be
228
+ # bracketed or the address is read as a port and a host.
229
+ forwarded = f"[{literal}]" if ":" in literal else literal
230
+
231
+ return (
232
+ "This address answers on the server itself and nowhere else.",
233
+ "To open it from your own machine, forward the port over SSH:",
234
+ f" ssh -L {port}:{forwarded}:{port} {ssh_target()}",
235
+ f"then browse to {scheme}://localhost:{port} there.",
236
+ )
237
+
238
+
239
+ __all__ = [
240
+ "ALL_INTERFACES",
241
+ "UNKNOWN_USER",
242
+ "WILDCARD_SPELLINGS",
243
+ "IPAddress",
244
+ "host_addresses",
245
+ "is_loopback_host",
246
+ "local_address",
247
+ "loopback_access_lines",
248
+ "normalize_host",
249
+ "server_address",
250
+ "ssh_target",
251
+ "strip_brackets",
252
+ ]
@@ -159,6 +159,30 @@ def detect_app_type(path: Path, verbose: bool = False) -> str | None:
159
159
  return DeployerRegistry.detect(path, verbose=verbose)
160
160
 
161
161
 
162
+ def available_types() -> list[dict[str, str]]:
163
+ """
164
+ List the application types anything in the product may offer.
165
+
166
+ The registry is the only place that knows what can be deployed, so it is
167
+ the only place that should answer this. The CLI used to carry a
168
+ hand-written list of eight strings and the panel had no list at all, which
169
+ is how a deployer could be added and reach neither front door.
170
+
171
+ Returns:
172
+ One mapping per type, with ``type`` and ``name``, ordered with the
173
+ detectors first and ``auto`` last: picking a specific type is the
174
+ deliberate choice, and offering "let WASM decide" at the top invites
175
+ the operator to skip a decision they usually know the answer to.
176
+ """
177
+ _import_deployers()
178
+
179
+ types = [
180
+ {"type": str(entry["type"]), "name": str(entry["name"])}
181
+ for entry in DeployerRegistry.list_deployers()
182
+ ]
183
+ return sorted(types, key=lambda entry: (entry["type"] == "auto", entry["name"].lower()))
184
+
185
+
162
186
  def _import_deployers() -> None:
163
187
  """
164
188
  Import every deployer module so that registration has happened.