wasm-cli 0.13.16__py3-none-any.whl → 0.14.1__py3-none-any.whl

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 (49) hide show
  1. wasm/__init__.py +1 -1
  2. wasm/cli/commands/backup.py +18 -10
  3. wasm/cli/commands/config.py +96 -0
  4. wasm/cli/commands/db.py +3 -2
  5. wasm/cli/commands/health.py +231 -0
  6. wasm/cli/commands/monitor.py +8 -3
  7. wasm/cli/commands/setup.py +51 -16
  8. wasm/cli/commands/store.py +20 -4
  9. wasm/cli/commands/webapp.py +158 -37
  10. wasm/cli/interactive.py +3 -2
  11. wasm/cli/parser.py +99 -18
  12. wasm/completions/_wasm +469 -32
  13. wasm/completions/wasm.bash +502 -21
  14. wasm/completions/wasm.fish +247 -4
  15. wasm/core/config.py +85 -1
  16. wasm/core/dependencies.py +51 -17
  17. wasm/core/exceptions.py +14 -0
  18. wasm/core/update_checker.py +66 -17
  19. wasm/core/utils.py +65 -3
  20. wasm/deployers/base.py +328 -286
  21. wasm/deployers/helpers/__init__.py +20 -0
  22. wasm/deployers/helpers/package_manager.py +183 -0
  23. wasm/deployers/helpers/path_resolver.py +188 -0
  24. wasm/deployers/helpers/prisma.py +130 -0
  25. wasm/deployers/vite.py +12 -0
  26. wasm/main.py +21 -5
  27. wasm/managers/backup_manager.py +179 -54
  28. wasm/managers/database/mysql.py +135 -47
  29. wasm/managers/database/postgres.py +85 -36
  30. wasm/managers/database/redis.py +14 -10
  31. wasm/managers/service_manager.py +91 -45
  32. wasm/monitor/__init__.py +15 -1
  33. wasm/monitor/ai_analyzer.py +1 -3
  34. wasm/monitor/process_monitor.py +135 -36
  35. wasm/monitor/threat_store.py +332 -0
  36. wasm/web/api/apps.py +6 -4
  37. wasm/web/api/backups.py +37 -17
  38. wasm/web/api/monitor.py +164 -10
  39. wasm/web/api/services.py +31 -25
  40. wasm/web/jobs.py +1 -1
  41. wasm/web/static/js/pages/monitor.js +69 -9
  42. wasm/web/websockets/router.py +8 -6
  43. wasm_cli-0.14.1.data/data/share/man/man1/wasm.1 +521 -0
  44. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/METADATA +4 -2
  45. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/RECORD +49 -41
  46. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/WHEEL +1 -1
  47. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/entry_points.txt +0 -0
  48. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/licenses/LICENSE +0 -0
  49. {wasm_cli-0.13.16.dist-info → wasm_cli-0.14.1.dist-info}/top_level.txt +0 -0
wasm/__init__.py CHANGED
@@ -8,7 +8,7 @@ WASM - Web App System Management
8
8
  A robust CLI tool for deploying and managing web applications on Linux servers.
9
9
  """
10
10
 
11
- __version__ = "0.13.16"
11
+ __version__ = "0.14.1"
12
12
  __author__ = "Yago López Prado"
13
13
  __license__ = "WASM-NCSAL"
14
14
 
@@ -105,6 +105,7 @@ def _backup_create(args: Namespace, verbose: bool) -> int:
105
105
  include_env = not getattr(args, "no_env", False)
106
106
  include_node_modules = getattr(args, "include_node_modules", False)
107
107
  include_build = getattr(args, "include_build", False)
108
+ include_databases = getattr(args, "include_databases", False)
108
109
  tags = getattr(args, "tags", None)
109
110
 
110
111
  if not domain:
@@ -127,6 +128,7 @@ def _backup_create(args: Namespace, verbose: bool) -> int:
127
128
  include_env=include_env,
128
129
  include_node_modules=include_node_modules,
129
130
  include_build=include_build,
131
+ include_databases=include_databases,
130
132
  tags=tag_list,
131
133
  )
132
134
 
@@ -135,7 +137,13 @@ def _backup_create(args: Namespace, verbose: bool) -> int:
135
137
  logger.info(f" Size: {metadata.size_human}")
136
138
  if metadata.git_commit:
137
139
  logger.info(f" Commit: {metadata.git_commit} ({metadata.git_branch})")
138
-
140
+ if metadata.database_backups:
141
+ db_count = len(metadata.database_backups)
142
+ logger.info(f" Databases: {db_count} backed up")
143
+ for db_info in metadata.database_backups:
144
+ size_mb = db_info.get("size_bytes", 0) / (1024 * 1024)
145
+ logger.info(f" - {db_info['engine']}/{db_info['name']} ({size_mb:.1f} MB)")
146
+
139
147
  return 0
140
148
 
141
149
  except BackupError as e:
@@ -195,7 +203,7 @@ def _backup_list(args: Namespace, verbose: bool) -> int:
195
203
  by_domain[backup.domain].append(backup)
196
204
 
197
205
  for dom, dom_backups in by_domain.items():
198
- logger.info(f"\n📦 {dom}")
206
+ logger.info(f"\n[{dom}]")
199
207
  _print_backup_table(dom_backups, logger, indent=True)
200
208
 
201
209
  return 0
@@ -226,7 +234,7 @@ def _print_backup_table(backups, logger, indent: bool = False):
226
234
  desc_str = f" - {backup.description}"
227
235
 
228
236
  logger.info(
229
- f"{prefix} {backup.id}: {backup.size_human}, "
237
+ f"{prefix}- {backup.id}: {backup.size_human}, "
230
238
  f"{backup.age}{commit_str}{tags_str}{desc_str}"
231
239
  )
232
240
 
@@ -359,17 +367,17 @@ def _backup_verify(args: Namespace, verbose: bool) -> int:
359
367
 
360
368
  if result["valid"]:
361
369
  logger.success("Backup is valid")
362
- if result.get("checksum_verified"):
363
- logger.info(" Checksum verified")
364
- if result.get("archive_valid"):
365
- logger.info(f" Archive valid ({result.get('file_count', '?')} files)")
370
+ if result.get("checksum_ok"):
371
+ logger.info(" [OK] Checksum verified")
372
+ if result.get("files_ok"):
373
+ logger.info(f" [OK] Archive valid ({result.get('file_count', '?')} files)")
366
374
  else:
367
375
  logger.error("Backup is invalid")
368
376
  for err in result["errors"]:
369
- logger.error(f" {err}")
370
-
377
+ logger.error(f" [ERROR] {err}")
378
+
371
379
  for warn in result.get("warnings", []):
372
- logger.warning(f" {warn}")
380
+ logger.warning(f" [WARN] {warn}")
373
381
 
374
382
  return 0 if result["valid"] else 1
375
383
 
@@ -0,0 +1,96 @@
1
+ """
2
+ Config command handlers for WASM.
3
+
4
+ Commands for managing WASM configuration files.
5
+ """
6
+
7
+ from argparse import Namespace
8
+
9
+ from wasm.core.config import Config, DEFAULT_CONFIG_PATH
10
+ from wasm.core.logger import Logger
11
+
12
+
13
+ def handle_config(args: Namespace) -> int:
14
+ """
15
+ Handle config commands.
16
+
17
+ Args:
18
+ args: Parsed arguments.
19
+
20
+ Returns:
21
+ Exit code.
22
+ """
23
+ action = args.action
24
+
25
+ handlers = {
26
+ "upgrade": _handle_upgrade,
27
+ "show": _handle_show,
28
+ "path": _handle_path,
29
+ }
30
+
31
+ handler = handlers.get(action)
32
+ if handler:
33
+ return handler(args)
34
+
35
+ # No action specified, show help
36
+ logger = Logger(verbose=getattr(args, "verbose", False))
37
+ logger.info("Usage: wasm config <command>")
38
+ logger.info("")
39
+ logger.info("Commands:")
40
+ logger.info(" upgrade Upgrade config file with new defaults")
41
+ logger.info(" show Show current configuration")
42
+ logger.info(" path Show config file path")
43
+ return 0
44
+
45
+
46
+ def _handle_upgrade(args: Namespace) -> int:
47
+ """Handle config upgrade command."""
48
+ quiet = getattr(args, "quiet", False)
49
+ logger = Logger(verbose=getattr(args, "verbose", False))
50
+
51
+ if not quiet:
52
+ logger.info("Upgrading configuration file...")
53
+
54
+ config = Config()
55
+ result = config.upgrade()
56
+
57
+ if "error" in result:
58
+ if not quiet:
59
+ logger.error(f"Failed to upgrade config: {result['error']}")
60
+ return 1
61
+
62
+ if result["upgraded"]:
63
+ if not quiet:
64
+ logger.success(f"Configuration upgraded! Added {len(result['added_keys'])} new option(s):")
65
+ for key in result["added_keys"][:10]: # Show first 10
66
+ logger.info(f" + {key}")
67
+ if len(result["added_keys"]) > 10:
68
+ logger.info(f" ... and {len(result['added_keys']) - 10} more")
69
+ else:
70
+ if not quiet:
71
+ logger.success("Configuration is already up to date")
72
+
73
+ return 0
74
+
75
+
76
+ def _handle_show(args: Namespace) -> int:
77
+ """Handle config show command."""
78
+ import yaml
79
+
80
+ logger = Logger(verbose=getattr(args, "verbose", False))
81
+ config = Config()
82
+
83
+ logger.header("Current Configuration")
84
+ print(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
85
+
86
+ return 0
87
+
88
+
89
+ def _handle_path(args: Namespace) -> int:
90
+ """Handle config path command."""
91
+ logger = Logger(verbose=getattr(args, "verbose", False))
92
+
93
+ logger.key_value("Config file", str(DEFAULT_CONFIG_PATH))
94
+ logger.key_value("Exists", "Yes" if DEFAULT_CONFIG_PATH.exists() else "No")
95
+
96
+ return 0
wasm/cli/commands/db.py CHANGED
@@ -934,9 +934,10 @@ def _db_connect(args: Namespace, verbose: bool) -> int:
934
934
 
935
935
  logger.info(f"Connecting to {manager.DISPLAY_NAME}...")
936
936
  logger.info(f"Command: {' '.join(cmd)}")
937
-
937
+
938
938
  # Execute interactively
939
- return subprocess.call(cmd)
939
+ result = subprocess.run(cmd)
940
+ return result.returncode
940
941
  except Exception as e:
941
942
  logger.error(str(e))
942
943
  return 1
@@ -0,0 +1,231 @@
1
+ # Copyright (c) 2024-2025 Yago López Prado
2
+ # Licensed under WASM-NCSAL 1.0 (Commercial use prohibited)
3
+ # https://github.com/Perkybeet/wasm/blob/main/LICENSE
4
+
5
+ """
6
+ Health check command for WASM.
7
+
8
+ Provides system-wide health diagnostics.
9
+ """
10
+
11
+ import shutil
12
+ from argparse import Namespace
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from wasm.core.config import Config
17
+ from wasm.core.logger import Logger
18
+
19
+
20
+ def _print_status(logger: Logger, key: str, value: str, status: str) -> None:
21
+ """Print a key-value pair with status indicator."""
22
+ if status == "ok":
23
+ indicator = "\033[32m[OK]\033[0m"
24
+ elif status == "warning":
25
+ indicator = "\033[33m[!]\033[0m"
26
+ elif status == "error":
27
+ indicator = "\033[31m[X]\033[0m"
28
+ else: # info
29
+ indicator = "\033[34m[i]\033[0m"
30
+
31
+ print(f" {indicator} {key}: {value}")
32
+
33
+
34
+ def handle_health(args: Namespace) -> int:
35
+ """
36
+ Handle the health check command.
37
+
38
+ Performs system diagnostics and shows overall health status.
39
+ """
40
+ logger = Logger(verbose=args.verbose)
41
+ config = Config()
42
+
43
+ logger.header("System Health Check")
44
+ logger.blank()
45
+
46
+ issues = []
47
+ warnings = []
48
+
49
+ # 1. Check disk space
50
+ logger.info("Checking disk space...")
51
+ try:
52
+ apps_dir = config.apps_directory
53
+ if apps_dir.exists():
54
+ stat = shutil.disk_usage(str(apps_dir))
55
+ free_gb = stat.free / (1024 ** 3)
56
+ total_gb = stat.total / (1024 ** 3)
57
+ used_percent = ((stat.total - stat.free) / stat.total) * 100
58
+
59
+ if free_gb < 1.0:
60
+ issues.append(f"Low disk space: {free_gb:.1f}GB free")
61
+ _print_status(logger, "Disk Space", f"{free_gb:.1f}GB free / {total_gb:.1f}GB total ({used_percent:.0f}% used)", "error")
62
+ elif free_gb < 5.0:
63
+ warnings.append(f"Disk space is getting low: {free_gb:.1f}GB free")
64
+ _print_status(logger, "Disk Space", f"{free_gb:.1f}GB free / {total_gb:.1f}GB total ({used_percent:.0f}% used)", "warning")
65
+ else:
66
+ _print_status(logger, "Disk Space", f"{free_gb:.1f}GB free / {total_gb:.1f}GB total ({used_percent:.0f}% used)", "ok")
67
+ else:
68
+ _print_status(logger, "Disk Space", "Apps directory not found", "warning")
69
+ except Exception as e:
70
+ warnings.append(f"Could not check disk space: {e}")
71
+
72
+ # 2. Check web servers
73
+ logger.blank()
74
+ logger.info("Checking web servers...")
75
+
76
+ from wasm.managers.nginx_manager import NginxManager
77
+ from wasm.managers.apache_manager import ApacheManager
78
+
79
+ nginx = NginxManager(verbose=args.verbose)
80
+ apache = ApacheManager(verbose=args.verbose)
81
+
82
+ nginx_installed = nginx.is_installed()
83
+ apache_installed = apache.is_installed()
84
+
85
+ if nginx_installed:
86
+ nginx_status = nginx.get_status()
87
+ if nginx_status.get("active"):
88
+ _print_status(logger, "Nginx", "Running", "ok")
89
+ else:
90
+ issues.append("Nginx is installed but not running")
91
+ _print_status(logger, "Nginx", "Stopped", "error")
92
+ else:
93
+ _print_status(logger, "Nginx", "Not installed", "info")
94
+
95
+ if apache_installed:
96
+ apache_status = apache.get_status()
97
+ if apache_status.get("active"):
98
+ _print_status(logger, "Apache", "Running", "ok")
99
+ else:
100
+ warnings.append("Apache is installed but not running")
101
+ _print_status(logger, "Apache", "Stopped", "warning")
102
+ else:
103
+ _print_status(logger, "Apache", "Not installed", "info")
104
+
105
+ if not nginx_installed and not apache_installed:
106
+ issues.append("No web server installed")
107
+
108
+ # 3. Check deployed applications
109
+ logger.blank()
110
+ logger.info("Checking deployed applications...")
111
+
112
+ from wasm.core.store import get_store
113
+ from wasm.managers.service_manager import ServiceManager
114
+
115
+ store = get_store()
116
+ service_manager = ServiceManager(verbose=args.verbose)
117
+
118
+ apps = store.list_apps()
119
+ apps_running = 0
120
+ apps_stopped = 0
121
+ apps_failed = 0
122
+
123
+ for app in apps:
124
+ try:
125
+ status = service_manager.status(app.domain.replace(".", "-"))
126
+ if status.get("active"):
127
+ apps_running += 1
128
+ else:
129
+ apps_stopped += 1
130
+ warnings.append(f"App '{app.domain}' is not running")
131
+ except Exception:
132
+ apps_failed += 1
133
+
134
+ total_apps = len(apps)
135
+ if total_apps > 0:
136
+ if apps_stopped > 0 or apps_failed > 0:
137
+ _print_status(logger, "Applications", f"{apps_running}/{total_apps} running, {apps_stopped} stopped", "warning")
138
+ else:
139
+ _print_status(logger, "Applications", f"{apps_running}/{total_apps} running", "ok")
140
+ else:
141
+ _print_status(logger, "Applications", "No applications deployed", "info")
142
+
143
+ # 4. Check SSL certificates
144
+ logger.blank()
145
+ logger.info("Checking SSL certificates...")
146
+
147
+ from wasm.managers.cert_manager import CertManager
148
+ cert_manager = CertManager(verbose=args.verbose)
149
+
150
+ try:
151
+ certs = cert_manager.list_certificates()
152
+ expiring_soon = []
153
+
154
+ for cert in certs:
155
+ if cert.get("expires"):
156
+ try:
157
+ expires = datetime.fromisoformat(cert["expires"].replace("Z", "+00:00"))
158
+ days_left = (expires - datetime.now(expires.tzinfo)).days
159
+ if days_left < 7:
160
+ issues.append(f"Certificate for {cert['domain']} expires in {days_left} days")
161
+ expiring_soon.append(cert["domain"])
162
+ elif days_left < 30:
163
+ warnings.append(f"Certificate for {cert['domain']} expires in {days_left} days")
164
+ expiring_soon.append(cert["domain"])
165
+ except Exception:
166
+ pass
167
+
168
+ if expiring_soon:
169
+ _print_status(logger, "SSL Certificates", f"{len(certs)} total, {len(expiring_soon)} expiring soon", "warning")
170
+ elif certs:
171
+ _print_status(logger, "SSL Certificates", f"{len(certs)} total, all valid", "ok")
172
+ else:
173
+ _print_status(logger, "SSL Certificates", "None configured", "info")
174
+ except Exception as e:
175
+ _print_status(logger, "SSL Certificates", f"Could not check: {e}", "warning")
176
+
177
+ # 5. Check system resources
178
+ logger.blank()
179
+ logger.info("Checking system resources...")
180
+
181
+ try:
182
+ # Memory check using /proc/meminfo
183
+ with open("/proc/meminfo") as f:
184
+ meminfo = {}
185
+ for line in f:
186
+ parts = line.split(":")
187
+ if len(parts) == 2:
188
+ key = parts[0].strip()
189
+ value = parts[1].strip().split()[0]
190
+ meminfo[key] = int(value)
191
+
192
+ total_mem = meminfo.get("MemTotal", 0) / 1024 / 1024 # GB
193
+ free_mem = (meminfo.get("MemAvailable", 0) or meminfo.get("MemFree", 0)) / 1024 / 1024 # GB
194
+ used_percent = ((total_mem - free_mem) / total_mem) * 100 if total_mem > 0 else 0
195
+
196
+ if used_percent > 90:
197
+ issues.append(f"High memory usage: {used_percent:.0f}%")
198
+ _print_status(logger, "Memory", f"{free_mem:.1f}GB free / {total_mem:.1f}GB total ({used_percent:.0f}% used)", "error")
199
+ elif used_percent > 75:
200
+ warnings.append(f"Memory usage is high: {used_percent:.0f}%")
201
+ _print_status(logger, "Memory", f"{free_mem:.1f}GB free / {total_mem:.1f}GB total ({used_percent:.0f}% used)", "warning")
202
+ else:
203
+ _print_status(logger, "Memory", f"{free_mem:.1f}GB free / {total_mem:.1f}GB total ({used_percent:.0f}% used)", "ok")
204
+ except Exception as e:
205
+ _print_status(logger, "Memory", f"Could not check: {e}", "warning")
206
+
207
+ # Summary
208
+ logger.blank()
209
+ logger.blank()
210
+
211
+ if issues:
212
+ logger.error(f"Health check found {len(issues)} issue(s):")
213
+ for issue in issues:
214
+ logger.error(f" - {issue}")
215
+ logger.blank()
216
+
217
+ if warnings:
218
+ logger.warning(f"Health check found {len(warnings)} warning(s):")
219
+ for warning in warnings:
220
+ logger.warning(f" - {warning}")
221
+ logger.blank()
222
+
223
+ if not issues and not warnings:
224
+ logger.success("All systems healthy!")
225
+ return 0
226
+ elif issues:
227
+ logger.error("System has issues that need attention.")
228
+ return 1
229
+ else:
230
+ logger.warning("System is healthy with minor warnings.")
231
+ return 0
@@ -9,6 +9,11 @@ from argparse import Namespace
9
9
 
10
10
  from wasm.core.logger import Logger
11
11
  from wasm.core.exceptions import WASMError, MonitorError, EmailError
12
+ from wasm.monitor import (
13
+ DEFAULT_SCAN_INTERVAL,
14
+ DEFAULT_CPU_THRESHOLD,
15
+ DEFAULT_MEMORY_THRESHOLD,
16
+ )
12
17
 
13
18
 
14
19
  def handle_monitor(args: Namespace) -> int:
@@ -348,9 +353,9 @@ def _handle_config(args: Namespace) -> int:
348
353
  # General settings
349
354
  logger.info("")
350
355
  logger.key_value("Enabled", str(config.get("monitor.enabled", False)))
351
- logger.key_value("Scan Interval", f"{config.get('monitor.scan_interval', 3600)}s")
352
- logger.key_value("CPU Threshold", f"{config.get('monitor.cpu_threshold', 80.0)}%")
353
- logger.key_value("Memory Threshold", f"{config.get('monitor.memory_threshold', 80.0)}%")
356
+ logger.key_value("Scan Interval", f"{config.get('monitor.scan_interval', DEFAULT_SCAN_INTERVAL)}s")
357
+ logger.key_value("CPU Threshold", f"{config.get('monitor.cpu_threshold', DEFAULT_CPU_THRESHOLD)}%")
358
+ logger.key_value("Memory Threshold", f"{config.get('monitor.memory_threshold', DEFAULT_MEMORY_THRESHOLD)}%")
354
359
 
355
360
  # Actions
356
361
  logger.info("")
@@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Tuple
12
12
  from wasm.core.logger import Logger
13
13
  from wasm.core.exceptions import WASMError
14
14
  from wasm.core.config import DEFAULT_APPS_DIR, DEFAULT_LOG_DIR, DEFAULT_CONFIG_PATH
15
- from wasm.core.utils import command_exists, run_command, run_command_sudo
15
+ from wasm.core.utils import command_exists, run_command, run_command_sudo, run_trusted_installer
16
16
 
17
17
 
18
18
  def handle_setup(args: Namespace) -> int:
@@ -361,20 +361,20 @@ def _handle_init(args: Namespace) -> int:
361
361
 
362
362
  if config_choices.get("install_nodejs") and not command_exists("node"):
363
363
  logger.substep("Installing Node.js 20.x LTS...")
364
-
365
- # Install Node.js from NodeSource
366
- result = run_command(
367
- "curl -fsSL https://deb.nodesource.com/setup_20.x | bash -",
368
- shell=True
369
- )
370
- if result.success:
371
- result = run_command_sudo(["apt-get", "install", "-y", "nodejs"])
364
+
365
+ # Install Node.js from NodeSource using trusted installer
366
+ try:
367
+ result = run_trusted_installer("https://deb.nodesource.com/setup_20.x")
372
368
  if result.success:
373
- logger.success("Node.js installed")
369
+ result = run_command_sudo(["apt-get", "install", "-y", "nodejs"])
370
+ if result.success:
371
+ logger.success("Node.js installed")
372
+ else:
373
+ logger.warning(f"Failed to install Node.js: {result.stderr}")
374
374
  else:
375
- logger.warning(f"Failed to install Node.js: {result.stderr}")
376
- else:
377
- logger.warning("Failed to setup Node.js repository. Please install manually.")
375
+ logger.warning("Failed to setup Node.js repository. Please install manually.")
376
+ except Exception as e:
377
+ logger.warning(f"Failed to setup Node.js: {e}")
378
378
 
379
379
  # Install package managers
380
380
  selected_pms = config_choices.get("package_managers", ["npm"])
@@ -394,8 +394,12 @@ def _handle_init(args: Namespace) -> int:
394
394
  elif pm == "yarn":
395
395
  result = run_command_sudo(["npm", "install", "-g", "yarn"])
396
396
  elif pm == "bun":
397
- # Bun has its own installer
398
- result = run_command("curl -fsSL https://bun.sh/install | bash", shell=True)
397
+ # Bun has its own trusted installer
398
+ try:
399
+ result = run_trusted_installer("https://bun.sh/install")
400
+ except Exception as e:
401
+ logger.warning(f"Failed to install bun: {e}")
402
+ continue
399
403
  else:
400
404
  result = run_command_sudo(["npm", "install", "-g", pm])
401
405
 
@@ -467,7 +471,38 @@ def _handle_init(args: Namespace) -> int:
467
471
  logger.success(f"Created {DEFAULT_CONFIG_PATH}")
468
472
  except Exception as e:
469
473
  logger.warning(f"Could not save config file: {e}")
470
-
474
+
475
+ # Install man page
476
+ logger.substep("Installing man page...")
477
+ try:
478
+ # Search for man page in multiple locations
479
+ man_source = None
480
+ search_paths = [
481
+ # Development: project root
482
+ Path(__file__).parent.parent.parent.parent.parent / "man" / "wasm.1",
483
+ # Installed via pip with data_files
484
+ Path("/usr/local/share/man/man1/wasm.1"),
485
+ Path("/usr/share/man/man1/wasm.1"),
486
+ ]
487
+
488
+ for path in search_paths:
489
+ if path.exists():
490
+ man_source = path
491
+ break
492
+
493
+ if man_source and man_source != Path("/usr/share/man/man1/wasm.1"):
494
+ man_dest = Path("/usr/share/man/man1/wasm.1")
495
+ shutil.copy(man_source, man_dest)
496
+ os.chmod(man_dest, 0o644)
497
+ run_command(["mandb", "-q"])
498
+ logger.success("Man page installed (man wasm)")
499
+ elif man_source:
500
+ logger.substep("Man page already installed")
501
+ else:
502
+ logger.debug("Man page source not found, skipping")
503
+ except Exception as e:
504
+ logger.debug(f"Could not install man page: {e}")
505
+
471
506
  # =========================================================================
472
507
  # Final Summary
473
508
  # =========================================================================
@@ -313,13 +313,29 @@ def _store_import(args: Namespace, verbose: bool) -> int:
313
313
  else:
314
314
  logger.step(2, 3, "No Apache sites found")
315
315
 
316
- # 3. Import from systemd services (wasm-* prefix)
316
+ # 3. Import from systemd services (both legacy wasm-* and new format)
317
317
  logger.step(3, 3, "Scanning systemd services")
318
318
  if SYSTEMD_DIR.exists():
319
- for service_file in SYSTEMD_DIR.glob("wasm-*.service"):
319
+ # Find all potential WASM service files
320
+ service_files = list(SYSTEMD_DIR.glob("wasm-*.service"))
321
+ # Also check for services matching domain pattern (new format)
322
+ for sf in SYSTEMD_DIR.glob("*.service"):
323
+ name = sf.stem
324
+ # Skip if already found as wasm-* or if it's a system service
325
+ if name.startswith("wasm-") or not "-" in name:
326
+ continue
327
+ # Check if it looks like a domain-based name (has hyphen, no @ or other special chars)
328
+ if "@" not in name and name.count("-") >= 1:
329
+ service_files.append(sf)
330
+
331
+ for service_file in service_files:
320
332
  # Extract app name from service file name
321
- service_name = service_file.stem # wasm-example-com
322
- app_name = service_name[5:] # example-com (remove wasm- prefix)
333
+ service_name = service_file.stem
334
+ # Handle both legacy (wasm-example-com) and new format (example-com)
335
+ if service_name.startswith("wasm-"):
336
+ app_name = service_name[5:] # Remove wasm- prefix
337
+ else:
338
+ app_name = service_name
323
339
 
324
340
  if store.get_service(app_name):
325
341
  continue